├── src ├── __init__.py ├── apps │ ├── __init__.py │ ├── account │ │ ├── __init__.py │ │ ├── v1 │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ ├── services.py │ │ │ └── adapters.py │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── templates │ │ │ └── account │ │ │ │ ├── signup.html │ │ │ │ └── reset_password.html │ │ └── apps.py │ ├── cms │ │ ├── __init__.py │ │ ├── v1 │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ ├── filters.py │ │ │ ├── serializers.py │ │ │ └── views.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0002_initial.py │ │ ├── apps.py │ │ ├── admin.py │ │ └── models.py │ ├── common │ │ ├── __init__.py │ │ ├── management │ │ │ ├── __init__.py │ │ │ ├── commands │ │ │ │ ├── __init__.py │ │ │ │ └── startapp.py │ │ │ └── app_template │ │ │ │ ├── __init__.py-tpl │ │ │ │ ├── v1 │ │ │ │ ├── __init__.py-tpl │ │ │ │ ├── nested_serializers.py-tpl │ │ │ │ ├── filters.py-tpl │ │ │ │ ├── permissions.py-tpl │ │ │ │ ├── urls.py-tpl │ │ │ │ ├── serializers.py-tpl │ │ │ │ ├── tests.py-tpl │ │ │ │ └── views.py-tpl │ │ │ │ ├── migrations │ │ │ │ └── __init__.py-tpl │ │ │ │ ├── tasks.py-tpl │ │ │ │ ├── signals.py-tpl │ │ │ │ ├── apps.py-tpl │ │ │ │ ├── admin.py-tpl │ │ │ │ └── models.py-tpl │ │ ├── apps.py │ │ └── caches.py │ ├── device │ │ ├── __init__.py │ │ ├── v1 │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ ├── views.py │ │ │ └── serializers.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ ├── apps.py │ │ ├── utils.py │ │ ├── models.py │ │ └── admin.py │ ├── feed │ │ ├── __init__.py │ │ ├── v1 │ │ │ ├── __init__.py │ │ │ ├── fields.py │ │ │ ├── paginations.py │ │ │ ├── urls.py │ │ │ └── filters.py │ │ ├── migrations │ │ │ └── __init__.py │ │ └── apps.py │ ├── file │ │ ├── __init__.py │ │ ├── v1 │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ ├── filters.py │ │ │ ├── tasks.py │ │ │ ├── utils.py │ │ │ ├── views.py │ │ │ └── serializers.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ ├── 0002_initial.py │ │ │ └── 0001_initial.py │ │ ├── apps.py │ │ └── models.py │ ├── game │ │ ├── __init__.py │ │ ├── v1 │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ ├── serializers.py │ │ │ └── views.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0001_initial.py │ │ ├── apps.py │ │ └── models.py │ ├── user │ │ ├── __init__.py │ │ ├── v1 │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ └── serializers.py │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── apps.py │ │ ├── signals.py │ │ ├── forms.py │ │ └── admin.py │ ├── agreement │ │ ├── __init__.py │ │ ├── v1 │ │ │ ├── __init__.py │ │ │ ├── paginations.py │ │ │ ├── tasks.py │ │ │ ├── urls.py │ │ │ ├── views.py │ │ │ └── serializers.py │ │ ├── migrations │ │ │ ├── __init__.py │ │ │ └── 0002_initial.py │ │ ├── apps.py │ │ ├── admin.py │ │ └── models.py │ └── short_url │ │ ├── __init__.py │ │ ├── v1 │ │ ├── __init__.py │ │ ├── urls.py │ │ ├── utils.py │ │ ├── views.py │ │ └── serializers.py │ │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ │ ├── apps.py │ │ ├── models.py │ │ ├── templates │ │ └── short_url │ │ │ └── redirect.html │ │ └── admin.py ├── base │ ├── __init__.py │ ├── enums │ │ ├── __init__.py │ │ └── base.py │ ├── fields │ │ ├── __init__.py │ │ └── encrypt.py │ └── utils │ │ ├── __init__.py │ │ └── aes_chipher.py ├── tests │ ├── __init__.py │ └── locust │ │ ├── __init__.py │ │ └── locustfile.py ├── conf │ ├── urls │ │ ├── __init__.py │ │ ├── url.py │ │ ├── admin.py │ │ └── api.py │ ├── settings │ │ ├── __init__.py │ │ ├── prod.py │ │ ├── stage.py │ │ ├── local.py │ │ └── develop.py │ ├── __init__.py │ ├── wsgi.py │ ├── hosts.py │ ├── celery.py │ ├── firebase_config.py │ ├── exceptions.py │ ├── filters.py │ ├── utils.py │ ├── routers.py │ ├── caches.py │ └── authentications.py ├── static │ └── favicon.ico ├── secrets │ └── serviceAccountKey.json ├── .env.example ├── manage.py ├── Makefile └── templates │ ├── otp │ ├── invalid_link.html │ ├── otp_already_set.html │ ├── error.html │ └── setup_otp.html │ └── admin │ └── login.html ├── docs ├── files │ ├── backoffice_login.png │ └── backoffice_dashboard.png ├── ko │ └── deploy │ │ └── docker-setup.md └── en │ └── deploy │ └── docker-setup.md ├── .gitignore ├── Dockerfile.celery ├── Dockerfile ├── .pylintrc ├── pyproject.toml └── docker-compose.yml /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/base/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/account/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/cms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/cms/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/device/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/feed/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/feed/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/file/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/file/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/game/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/game/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/user/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/base/enums/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/base/fields/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/base/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/conf/urls/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tests/locust/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/account/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/agreement/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/agreement/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/device/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/short_url/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/short_url/v1/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/conf/settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/cms/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/common/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/device/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/feed/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/file/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/game/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/user/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/account/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/agreement/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/short_url/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/common/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/__init__.py-tpl: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/v1/__init__.py-tpl: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/migrations/__init__.py-tpl: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/tasks.py-tpl: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | -------------------------------------------------------------------------------- /src/conf/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app as celery_app 2 | 3 | __all__ = ("celery_app",) 4 | -------------------------------------------------------------------------------- /src/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lee-lou2/django-boilerplate/HEAD/src/static/favicon.ico -------------------------------------------------------------------------------- /src/apps/account/templates/account/signup.html: -------------------------------------------------------------------------------- 1 |

다음 링크를 클릭하여 이메일을 인증하세요:

2 | 이메일 인증하기 -------------------------------------------------------------------------------- /src/apps/common/management/app_template/v1/nested_serializers.py-tpl: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | -------------------------------------------------------------------------------- /src/apps/account/templates/account/reset_password.html: -------------------------------------------------------------------------------- 1 |

다음 링크를 클릭하여 이메일을 인증하세요:

2 | 이메일 인증하기 -------------------------------------------------------------------------------- /docs/files/backoffice_login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lee-lou2/django-boilerplate/HEAD/docs/files/backoffice_login.png -------------------------------------------------------------------------------- /src/apps/cms/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CMSConfig(AppConfig): 5 | name = "apps.cms" 6 | -------------------------------------------------------------------------------- /docs/files/backoffice_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lee-lou2/django-boilerplate/HEAD/docs/files/backoffice_dashboard.png -------------------------------------------------------------------------------- /src/apps/feed/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class FeedConfig(AppConfig): 5 | name = "apps.feed" 6 | -------------------------------------------------------------------------------- /src/apps/file/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class FileConfig(AppConfig): 5 | name = "apps.file" 6 | -------------------------------------------------------------------------------- /src/apps/game/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class GameConfig(AppConfig): 5 | name = "apps.game" 6 | -------------------------------------------------------------------------------- /src/apps/account/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountConfig(AppConfig): 5 | name = "apps.account" 6 | -------------------------------------------------------------------------------- /src/apps/common/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CommonConfig(AppConfig): 5 | name = "apps.common" 6 | -------------------------------------------------------------------------------- /src/apps/short_url/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ShortUrlConfig(AppConfig): 5 | name = "apps.short_url" 6 | -------------------------------------------------------------------------------- /src/apps/agreement/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AgreementConfig(AppConfig): 5 | name = "apps.agreement" 6 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/signals.py-tpl: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save 2 | from django.dispatch import receiver 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | src/staticfiles 3 | .idea 4 | *.pyc 5 | src/logs 6 | .venv 7 | 8 | .env 9 | 10 | db.sqlite3 11 | 12 | .kiro 13 | -------------------------------------------------------------------------------- /src/apps/user/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UserConfig(AppConfig): 5 | name = "apps.user" 6 | 7 | def ready(self): 8 | import apps.user.signals 9 | -------------------------------------------------------------------------------- /src/conf/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "conf.settings.local") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /src/apps/feed/v1/fields.py: -------------------------------------------------------------------------------- 1 | class CurrentFeedDefault: 2 | """현재 조회된 피드 설정""" 3 | 4 | requires_context = True 5 | 6 | def __call__(self, serializer_field): 7 | return serializer_field.context["view"].kwargs["feed_pk"] 8 | -------------------------------------------------------------------------------- /src/conf/urls/url.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from apps.short_url.v1.views import ShortUrlRedirectView 4 | 5 | urlpatterns = [ 6 | path("/", ShortUrlRedirectView.as_view(), name="short-url-redirect"), 7 | ] 8 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/v1/filters.py-tpl: -------------------------------------------------------------------------------- 1 | import django_filters 2 | 3 | from apps.{{ app_name }}.models import {{ camel_case_app_name }} 4 | 5 | 6 | class {{ camel_case_app_name }}Filter(django_filters.FilterSet): 7 | pass 8 | -------------------------------------------------------------------------------- /src/base/enums/base.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class DjangoEnvironment(enum.Enum): 5 | """Django 환경""" 6 | 7 | LOCAL = "local" 8 | DEVELOP = "develop" 9 | STAGE = "stage" 10 | PROD = "prod" 11 | TEST = "test" 12 | -------------------------------------------------------------------------------- /src/apps/file/v1/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework_nested import routers 2 | 3 | from apps.file.v1.views import FileViewSet 4 | 5 | router = routers.SimpleRouter() 6 | router.register("file", FileViewSet, basename="file") 7 | 8 | urlpatterns = router.urls 9 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/apps.py-tpl: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class {{ camel_case_app_name }}Config(AppConfig): 5 | name = "apps.{{ app_name }}" 6 | 7 | def ready(self): 8 | import apps.{{ app_name }}.signals 9 | -------------------------------------------------------------------------------- /src/apps/short_url/v1/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | 3 | from apps.short_url.v1.views import ShortUrlViewSet 4 | 5 | router = SimpleRouter() 6 | router.register("short-url", ShortUrlViewSet, basename="short-url") 7 | 8 | urlpatterns = router.urls 9 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/admin.py-tpl: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from apps.{{ app_name }}.models import {{ camel_case_app_name }} 4 | 5 | 6 | @admin.register({{ camel_case_app_name }}) 7 | class {{ camel_case_app_name }}Admin(admin.ModelAdmin): 8 | pass 9 | -------------------------------------------------------------------------------- /src/conf/hosts.py: -------------------------------------------------------------------------------- 1 | from django_hosts import host, patterns 2 | 3 | 4 | host_patterns = patterns( 5 | "", 6 | host("api", "conf.urls.api", name="api"), 7 | host("admin", "conf.urls.admin", name="admin"), 8 | host("url", "conf.urls.url", name="short-url"), 9 | ) 10 | -------------------------------------------------------------------------------- /src/apps/agreement/v1/paginations.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import LimitOffsetPagination 2 | 3 | 4 | class AgreementLimitOffsetPagination(LimitOffsetPagination): 5 | """약관 페이지네이션""" 6 | 7 | ordering = ["-order"] 8 | default_limit = 10 9 | max_limit = 100 10 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/models.py-tpl: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class {{ camel_case_app_name }}(models.Model): 5 | class Meta: 6 | db_table = "{{ app_name }}" 7 | verbose_name = "{{ camel_case_app_name }}" 8 | verbose_name_plural = verbose_name 9 | -------------------------------------------------------------------------------- /src/apps/user/signals.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.signals import user_logged_in 2 | from django.dispatch import receiver 3 | 4 | from apps.user.models import User 5 | 6 | 7 | @receiver(user_logged_in, sender=User) 8 | def post_login(sender, request, user, **kwargs): 9 | """로그인 시 처리""" 10 | 11 | pass 12 | -------------------------------------------------------------------------------- /src/conf/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | 5 | 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "conf.settings.local") 7 | 8 | app = Celery("conf") 9 | app.config_from_object("django.conf:settings", namespace="CELERY") 10 | app.autodiscover_tasks() 11 | app.conf.timezone = "Asia/Seoul" 12 | -------------------------------------------------------------------------------- /src/apps/device/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DeviceConfig(AppConfig): 5 | name = "apps.device" 6 | 7 | def ready(self): 8 | import sys 9 | 10 | if "runserver" in sys.argv or "gunicorn" in sys.argv: 11 | from conf import firebase_config 12 | 13 | firebase_config.initialize_firebase() 14 | -------------------------------------------------------------------------------- /src/apps/agreement/v1/tasks.py: -------------------------------------------------------------------------------- 1 | from conf.celery import app 2 | 3 | 4 | @app.task 5 | def task_send_re_agreement_notification( 6 | previous_version: "Agreement", latest_version: "Agreement" 7 | ): 8 | """ 9 | 약관 재동의 요청: 10 | 필수 약관 변경 시 재동의 필요 11 | 이메일이나 푸시 등을 통해 재동의 요청하는 태스크 12 | """ 13 | # TODO: 이메일 발송 예약 14 | # TODO: 푸시 발송 예약 15 | pass 16 | -------------------------------------------------------------------------------- /src/apps/feed/v1/paginations.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import CursorPagination 2 | 3 | 4 | class FeedCursorPagination(CursorPagination): 5 | """피드 페이지네이션""" 6 | 7 | ordering = "-published_at" 8 | page_size = 10 9 | 10 | 11 | class FeedCommentCursorPagination(CursorPagination): 12 | """피드 댓글 페이지네이션""" 13 | 14 | ordering = "-created_at" 15 | page_size = 10 16 | -------------------------------------------------------------------------------- /src/conf/urls/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | from apps.user.forms import OTPAuthenticationForm 5 | from apps.user.v1 import views as user_views 6 | 7 | admin.site.login_form = OTPAuthenticationForm 8 | 9 | urlpatterns = [ 10 | path("admin/", admin.site.urls), 11 | path("setup-otp///", user_views.setup_otp_view, name="setup_otp"), 12 | ] 13 | -------------------------------------------------------------------------------- /src/apps/cms/v1/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | 3 | from apps.cms.v1.views import NoticeViewSet, EventViewSet, FaqViewSet 4 | 5 | router = SimpleRouter() 6 | router.register("cms/notice", NoticeViewSet, basename="cms-notice") 7 | router.register("cms/event", EventViewSet, basename="cms-event") 8 | router.register("cms/faq", FaqViewSet, basename="cms-faq") 9 | 10 | urlpatterns = router.urls 11 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/v1/permissions.py-tpl: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | 3 | 4 | class {{ camel_case_app_name }}Permission(permissions.BasePermission): 5 | def has_permission(self, request, view): 6 | return super().has_permission(request, view) 7 | 8 | def has_object_permission(self, request, view, obj): 9 | return super().has_object_permission(request, view, obj) 10 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/v1/urls.py-tpl: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from rest_framework.routers import SimpleRouter 3 | 4 | from apps.{{ app_name }}.v1.views import {{ camel_case_app_name }}ViewSet 5 | 6 | router = SimpleRouter() 7 | router.register("{{ app_name }}", {{ camel_case_app_name }}ViewSet, basename="{{ app_name }}") 8 | 9 | urlpatterns = [ 10 | path("", include(router.urls)), 11 | ] 12 | -------------------------------------------------------------------------------- /src/conf/settings/prod.py: -------------------------------------------------------------------------------- 1 | from conf.settings.base import * 2 | 3 | # CORS 4 | CORS_ORIGIN_ALLOW_ALL = False 5 | CORS_ALLOWED_ORIGINS = [ 6 | # 허용 오리진 추가 7 | ] 8 | CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") 9 | 10 | # LOGGING LEVEL 11 | LOGGING["loggers"]["django"]["level"] = "INFO" 12 | LOGGING["loggers"]["django.db.backends"]["level"] = "INFO" 13 | LOGGING["loggers"]["default"]["level"] = "INFO" 14 | -------------------------------------------------------------------------------- /src/conf/settings/stage.py: -------------------------------------------------------------------------------- 1 | from conf.settings.base import * 2 | 3 | # CORS 4 | CORS_ORIGIN_ALLOW_ALL = False 5 | CORS_ALLOWED_ORIGINS = [ 6 | # 허용 오리진 추가 7 | ] 8 | CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") 9 | 10 | # LOGGING LEVEL 11 | LOGGING["loggers"]["django"]["level"] = "INFO" 12 | LOGGING["loggers"]["django.db.backends"]["level"] = "INFO" 13 | LOGGING["loggers"]["default"]["level"] = "INFO" 14 | -------------------------------------------------------------------------------- /src/conf/settings/local.py: -------------------------------------------------------------------------------- 1 | from conf.settings.base import * 2 | 3 | # 로컬 이메일 발송 4 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 5 | 6 | # LOGGING LEVEL 7 | LOGGING["loggers"]["django"]["level"] = "INFO" 8 | LOGGING["loggers"]["django.db.backends"]["level"] = "INFO" 9 | LOGGING["loggers"]["default"]["level"] = "DEBUG" 10 | 11 | SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"] = timedelta(days=30) 12 | SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"] = timedelta(days=365) 13 | -------------------------------------------------------------------------------- /src/secrets/serviceAccountKey.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "", 4 | "private_key_id": "", 5 | "private_key": "", 6 | "client_email": "", 7 | "client_id": "", 8 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 9 | "token_uri": "https://oauth2.googleapis.com/token", 10 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 11 | "client_x509_cert_url": "", 12 | "universe_domain": "googleapis.com" 13 | } 14 | -------------------------------------------------------------------------------- /src/apps/agreement/v1/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | 3 | from apps.agreement.v1.views import ( 4 | AgreementViewSet, 5 | UserAgreementViewSet, 6 | ) 7 | 8 | router = SimpleRouter() 9 | router.register( 10 | "account/agreement", 11 | AgreementViewSet, 12 | basename="agreement", 13 | ) 14 | router.register( 15 | "user/me/agreement", 16 | UserAgreementViewSet, 17 | basename="user-agreement", 18 | ) 19 | 20 | urlpatterns = router.urls 21 | -------------------------------------------------------------------------------- /src/apps/file/v1/filters.py: -------------------------------------------------------------------------------- 1 | from django_filters import rest_framework as filters 2 | 3 | from apps.file.models import File 4 | 5 | 6 | class FileFilterSet(filters.FilterSet): 7 | """파일 필터셋""" 8 | 9 | content_type = filters.ModelChoiceFilter( 10 | field_name="content_type", 11 | lookup_expr="exact", 12 | required=True, 13 | label="컨텐츠 타입 (필수)", 14 | ) 15 | 16 | class Meta: 17 | model = File 18 | fields = { 19 | "uuid": ["exact"], 20 | "object_id": ["exact"], 21 | } 22 | -------------------------------------------------------------------------------- /src/apps/account/v1/urls.py: -------------------------------------------------------------------------------- 1 | from rest_framework.routers import SimpleRouter 2 | 3 | from apps.account.v1.views import ( 4 | LoginViewSet, 5 | RegisterViewSet, 6 | PasswordViewSet, 7 | GoogleLoginViewSet, 8 | ) 9 | 10 | router = SimpleRouter() 11 | router.register("account", LoginViewSet, basename="user") 12 | router.register("account/register", RegisterViewSet, basename="register") 13 | router.register("account/password", PasswordViewSet, basename="password") 14 | router.register("account/google", GoogleLoginViewSet, basename="google_login") 15 | 16 | urlpatterns = router.urls 17 | -------------------------------------------------------------------------------- /src/apps/feed/v1/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from rest_framework_nested import routers 3 | 4 | from apps.feed.v1.views import ( 5 | FeedViewSet, 6 | FeedCommentViewSet, 7 | ) 8 | 9 | feed_router = routers.SimpleRouter() 10 | feed_router.register("feed", FeedViewSet, basename="feed") 11 | 12 | comment_router = routers.NestedSimpleRouter(feed_router, r"feed", lookup="feed") 13 | comment_router.register(r"comment", FeedCommentViewSet, basename="feed-comment") 14 | 15 | urlpatterns = [ 16 | path("", include(feed_router.urls)), 17 | path("", include(comment_router.urls)), 18 | ] 19 | -------------------------------------------------------------------------------- /src/apps/game/v1/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from apps.game.v1.views import AttendanceCheckViewSet, AttendanceCheckRoleAPIView 4 | 5 | urlpatterns = [ 6 | path( 7 | "game/attendance-check/", 8 | AttendanceCheckViewSet.as_view( 9 | { 10 | "get": "retrieve", 11 | "post": "partial_update", 12 | } 13 | ), 14 | name="attendance-check", 15 | ), 16 | path( 17 | "game/attendance-check/role/", 18 | AttendanceCheckRoleAPIView.as_view(), 19 | name="attendance-check-role", 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /src/apps/file/v1/tasks.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | 3 | from apps.file.models import FileStatus, File 4 | from conf.celery import app 5 | 6 | 7 | @app.task 8 | def task_delete_expired_files(): 9 | """만료 파일 삭제""" 10 | expired_files = File.objects.exclude( 11 | status=FileStatus.DELETE, 12 | ).filter( 13 | expire_at__lt=timezone.now(), 14 | ) 15 | for expired_file in expired_files: 16 | # 파일 삭제 17 | ... 18 | # 상태 변경 19 | expired_file.status = FileStatus.DELETE 20 | expired_file.save(update_fields=["status"]) 21 | return f"{expired_files.count()} files deleted" 22 | -------------------------------------------------------------------------------- /src/apps/device/v1/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from rest_framework_nested import routers 3 | 4 | from apps.device.v1.views import DeviceViewSet, PushTokenViewSet 5 | 6 | device_router = routers.SimpleRouter() 7 | device_router.register("device", DeviceViewSet, basename="device") 8 | 9 | push_token_router = routers.NestedSimpleRouter( 10 | device_router, r"device", lookup="device" 11 | ) 12 | push_token_router.register( 13 | r"push_token", PushTokenViewSet, basename="device-push-token" 14 | ) 15 | 16 | urlpatterns = [ 17 | path("", include(device_router.urls)), 18 | path("", include(push_token_router.urls)), 19 | ] 20 | -------------------------------------------------------------------------------- /src/.env.example: -------------------------------------------------------------------------------- 1 | DEBUG=True 2 | 3 | # Email 4 | EMAIL_HOST= 5 | EMAIL_PORT=587 6 | EMAIL_HOST_USER= 7 | EMAIL_HOST_PASSWORD= 8 | DEFAULT_FROM_EMAIL= 9 | 10 | # Secrets 11 | SECRET_KEY=secret 12 | USER_ENCRYPTION_KEY=abcd1234abcd1234abcd1234abcd1234 13 | 14 | # Account 15 | SIGNUP_CONFIRM_URL=http://localhost:8000/v1/account/register/confirm/ 16 | RESET_PASSWORD_URL=http://localhost:8000/v1/account/password/change/ 17 | SIGNUP_COMPLETED_URL=https://homepage.com/login/ 18 | 19 | # Google 20 | GOOGLE_CLIENT_ID= 21 | GOOGLE_CLIENT_SECRET= 22 | GOOGLE_REDIRECT_URI= 23 | GOOGLE_PROJECT_ID= 24 | 25 | # AWS 26 | AWS_REGION_NAME=ap-northeast-2 27 | AWS_ACCESS_KEY_ID= 28 | AWS_SECRET_ACCESS_KEY= 29 | -------------------------------------------------------------------------------- /src/conf/firebase_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import firebase_admin 4 | from django.conf import settings 5 | from firebase_admin import credentials 6 | 7 | 8 | def initialize_firebase(): 9 | """Firebase Admin SDK 초기화""" 10 | # 이미 초기화 되었는지 확인 11 | if not firebase_admin._apps: 12 | try: 13 | service_account_key_path = os.path.join( 14 | settings.BASE_DIR, "secrets/serviceAccountKey.json" 15 | ) 16 | cred = credentials.Certificate(service_account_key_path) 17 | firebase_admin.initialize_app(cred) 18 | print("Firebase Admin SDK initialized successfully.") 19 | except Exception as e: 20 | print(f"Error initializing Firebase Admin SDK: {e}") 21 | -------------------------------------------------------------------------------- /src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "conf.settings.local") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /src/apps/user/v1/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from apps.user.v1.views import ( 4 | UserProfileViewSet, 5 | UserPreferenceViewSet, 6 | ) 7 | 8 | urlpatterns = [ 9 | path( 10 | "user/me/profile/", 11 | UserProfileViewSet.as_view( 12 | { 13 | "get": "retrieve", 14 | "post": "create", 15 | "put": "update", 16 | } 17 | ), 18 | name="user-profile", 19 | ), 20 | path( 21 | "user/me/preference/", 22 | UserPreferenceViewSet.as_view( 23 | { 24 | "get": "retrieve", 25 | "patch": "partial_update", 26 | } 27 | ), 28 | name="user-preference", 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /src/conf/exceptions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.exceptions import ValidationError 2 | from rest_framework.views import exception_handler 3 | 4 | 5 | def default_exception_handler(exc, context): 6 | """기본 오류 핸들러""" 7 | response = exception_handler(exc, context) 8 | if ( 9 | response is None 10 | or not hasattr(response, "data") 11 | or not isinstance(exc, ValidationError) 12 | or not isinstance(response.data, dict) 13 | ): 14 | return response 15 | # 오류 구조 변경 16 | for key, value in response.data.items(): 17 | response.data[key] = ( 18 | [{"message": item, "error_code": "E0000000"} for item in value] 19 | if isinstance(value, list) 20 | else [value] 21 | ) 22 | return response 23 | -------------------------------------------------------------------------------- /src/tests/locust/locustfile.py: -------------------------------------------------------------------------------- 1 | # --- Django Setup Start --- 2 | import os 3 | 4 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_project.settings.test") 5 | import django 6 | 7 | django.setup() 8 | # --- Django Setup End --- 9 | 10 | 11 | from locust import HttpUser, task, events, exception, constant 12 | 13 | 14 | @events.test_start.add_listener 15 | def on_test_start(environment, **kwargs): 16 | """테스트 시작 전""" 17 | print("--- Test Start: Creating Test DB and ... ---") 18 | pass 19 | 20 | 21 | @events.test_stop.add_listener 22 | def on_test_stop(environment, **kwargs): 23 | """테스트 종료 후""" 24 | print("--- Test Stop: Cleaning up... ---") 25 | pass 26 | 27 | 28 | class HealthCheckAPIUser(HttpUser): 29 | # wait_time = between(1, 3) 30 | wait_time = constant(0) 31 | 32 | @task(1) 33 | def health_check(self): 34 | self.client.get("/_health") 35 | -------------------------------------------------------------------------------- /src/apps/short_url/v1/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 5 | BASE = 62 6 | 7 | 8 | def generate_random_key(): 9 | """랜덤 키 생성""" 10 | return "".join(random.choices(string.ascii_letters + string.digits, k=4)) 11 | 12 | 13 | def id_to_key(idx: int): 14 | """ShortId to ShortKey""" 15 | if idx < 1: 16 | return None 17 | key = [] 18 | while idx > 0: 19 | idx -= 1 20 | digit = idx % BASE 21 | key.append(CHARS[digit]) 22 | idx //= BASE 23 | key.reverse() 24 | return "".join(key) 25 | 26 | 27 | def key_to_id(key: str): 28 | """ShotKey to ShortId""" 29 | result = 0 30 | for c in key: 31 | digit = CHARS.find(c) 32 | if digit == -1: 33 | return None 34 | result = result * BASE + (digit + 1) 35 | return result 36 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/v1/serializers.py-tpl: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from apps.{{ app_name }}.models import {{ camel_case_app_name }} 4 | 5 | 6 | class {{ camel_case_app_name }}Serializer(serializers.ModelSerializer): 7 | def to_internal_value(self, data): 8 | return super().to_internal_value(data) 9 | 10 | def validate(self, attrs): 11 | attrs = super().validate(attrs) 12 | return attrs 13 | 14 | def create(self, validated_data): 15 | return super().create(validated_data) 16 | 17 | def update(self, instance, validated_data): 18 | return super().update(instance, validated_data) 19 | 20 | def to_representation(self, instance): 21 | return super().to_representation(instance) 22 | 23 | class Meta: 24 | model = {{ camel_case_app_name }} 25 | fields = [ 26 | "id", 27 | ] 28 | -------------------------------------------------------------------------------- /src/apps/feed/v1/filters.py: -------------------------------------------------------------------------------- 1 | from django_filters import rest_framework as filters 2 | 3 | from apps.feed.models import Feed 4 | 5 | 6 | class FeedFilterSet(filters.FilterSet): 7 | """피드 필터셋""" 8 | 9 | tag = filters.CharFilter( 10 | field_name="tags__name", 11 | lookup_expr="icontains", 12 | help_text="태그", 13 | ) 14 | author = filters.UUIDFilter( 15 | field_name="user__uuid", 16 | lookup_expr="exact", 17 | help_text="작성자 UUID", 18 | ) 19 | 20 | class Meta: 21 | model = Feed 22 | fields = { 23 | "published_at": ["gte", "lte"], 24 | "uuid": ["exact"], 25 | "title": ["icontains"], 26 | } 27 | 28 | 29 | class FeedCommentFilterSet(filters.FilterSet): 30 | """피드 댓글 필터셋""" 31 | 32 | parent = filters.UUIDFilter( 33 | field_name="parent__uuid", 34 | lookup_expr="exact", 35 | help_text="부모 댓글 UUID", 36 | ) 37 | -------------------------------------------------------------------------------- /src/conf/filters.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | # 민감 정보 키워드 5 | SENSITIVE_KEYWORDS = { 6 | "password", 7 | "access_token", 8 | "refresh_token", 9 | } 10 | 11 | 12 | class SensitiveFilter(logging.Filter): 13 | """로깅시 민감 정보 마스킹 필터""" 14 | 15 | def filter(self, record): 16 | for keyword in SENSITIVE_KEYWORDS: 17 | record.msg = str(record.msg) 18 | if keyword in record.msg: 19 | record.msg = self.sanitize_dict(record.msg, keyword) 20 | return True 21 | 22 | @staticmethod 23 | def sanitize_dict(msg, keyword): 24 | regex_mapper = { 25 | f'"{keyword}":[ ]*"[^"]*"': f'"{keyword}": "****"', 26 | f"'{keyword}':[ ]*'[^']*'": f"'{keyword}': '****'", 27 | f"{keyword}=[^&]+&?": f"{keyword}=****&", 28 | } 29 | for regex, replace in regex_mapper.items(): 30 | msg = re.sub(regex, replace, msg) 31 | return msg 32 | -------------------------------------------------------------------------------- /src/Makefile: -------------------------------------------------------------------------------- 1 | # Django Project Makefile 2 | 3 | # Activate virtual environment and set python executable 4 | PYTHON = ../.venv/bin/python 5 | 6 | .PHONY: run test load-test 7 | 8 | # 1. Run development server 9 | dev: 10 | @echo "Starting development server..." 11 | $(PYTHON) manage.py runserver 12 | 13 | # 2. Run unit tests 14 | test: 15 | @echo "Running unit tests..." 16 | $(PYTHON) manage.py test 17 | 18 | # 3. Run server with test settings and perform load testing 19 | load-test: 20 | @echo "Starting server with test settings for load testing..." 21 | $(PYTHON) manage.py migrate --settings=django_project.settings.test && \ 22 | $(PYTHON) manage.py runserver --settings=django_project.settings.test & \ 23 | SERVER_PID=$$! && \ 24 | sleep 5 && \ 25 | $(PYTHON) -m locust -f tests/locust/locustfile.py --headless -u 10 -r 5 -t 30s --host=http://127.0.0.1:8000 --html=locust_report.html && \ 26 | pkill -P $$SERVER_PID && \ 27 | rm -f db.test.sqlite3 28 | @echo "Load test finished. Report saved to locust_report.html" -------------------------------------------------------------------------------- /Dockerfile.celery: -------------------------------------------------------------------------------- 1 | # 1단계: 베이스 이미지 설정 2 | FROM python:3.12-slim 3 | 4 | # 2단계: 환경 변수 설정 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | # 3단계: 작업 디렉토리 설정 9 | WORKDIR /app 10 | 11 | # 4단계: 의존성 설치 준비 12 | COPY requirements.txt . 13 | 14 | # 5단계: 시스템 및 Python 의존성 설치 15 | RUN apt-get update && \ 16 | apt-get install -y --no-install-recommends \ 17 | build-essential \ 18 | gcc \ 19 | libpq-dev \ 20 | libpq5 \ 21 | && pip install --upgrade pip && \ 22 | pip install --no-cache-dir -r requirements.txt && \ 23 | apt-get purge -y --auto-remove build-essential gcc libpq-dev && \ 24 | apt-get clean && \ 25 | rm -rf /var/lib/apt/lists/* 26 | 27 | # 6단계: 애플리케이션 코드 복사 28 | COPY src . 29 | 30 | # 7단계: 보안 강화를 위해 non-root 사용자 생성 및 사용 31 | RUN addgroup --system app && adduser --system --ingroup app app 32 | RUN chown -R app:app /app 33 | USER app 34 | 35 | # 8단계: Celery 워커 실행 명령 36 | CMD ["celery", "-A", "conf", "worker", "--loglevel=info", "--concurrency=1", "--pool=solo"] -------------------------------------------------------------------------------- /src/apps/cms/v1/filters.py: -------------------------------------------------------------------------------- 1 | from django_filters import rest_framework as filters 2 | 3 | from apps.cms.models import Notice, Event, Faq 4 | 5 | 6 | class NoticeFilterSet(filters.FilterSet): 7 | """공지사항 필터셋""" 8 | 9 | class Meta: 10 | model = Notice 11 | fields = { 12 | "title": ["exact", "icontains"], 13 | "published_at": ["gte", "lte"], 14 | } 15 | 16 | 17 | class EventFilterSet(filters.FilterSet): 18 | """이벤트 필터셋""" 19 | 20 | class Meta: 21 | model = Event 22 | fields = { 23 | "title": ["exact", "icontains"], 24 | "published_at": ["gte", "lte"], 25 | } 26 | 27 | 28 | class FaqFilterSet(filters.FilterSet): 29 | """FAQ 필터셋""" 30 | 31 | category_name = filters.CharFilter( 32 | field_name="category__name", 33 | lookup_expr="icontains", 34 | label="카테고리 이름", 35 | ) 36 | 37 | class Meta: 38 | model = Faq 39 | fields = { 40 | "published_at": ["gte", "lte"], 41 | } 42 | -------------------------------------------------------------------------------- /src/conf/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | 5 | import boto3 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def load_aws_parameters(path: str, region_name: str): 11 | """AWS 파라미터 스토어에서 환경변수 로드""" 12 | ssm = boto3.client(service_name="ssm", region_name=region_name) 13 | if ssm is None: 14 | return 15 | 16 | try: 17 | resp = ssm.get_parameter(Name=path, WithDecryption=True) 18 | if ( 19 | not resp 20 | or "ResponseMetadata" not in resp 21 | or "HTTPStatusCode" not in resp.get("ResponseMetadata", {}) 22 | or "Parameter" not in resp 23 | or resp.get("ResponseMetadata").get("HTTPStatusCode") != 200 24 | ): 25 | return 26 | 27 | parameter_value = resp.get("Parameter").get("Value") 28 | values = json.loads(parameter_value) 29 | for key, value in values.items(): 30 | os.environ[key] = value 31 | except Exception as e: 32 | logger.info(f"파라미터 스토어 환경 변수 조회 실패, 오류 내용 : {e}") 33 | -------------------------------------------------------------------------------- /src/apps/user/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib.auth import get_user_model 3 | from django.utils.translation import gettext_lazy as _ 4 | from django_otp.forms import OTPAuthenticationForm as DefaultOTPAuthenticationForm 5 | 6 | User = get_user_model() 7 | 8 | 9 | class OTPAuthenticationForm(DefaultOTPAuthenticationForm): 10 | def __init__(self, request=None, *args, **kwargs): 11 | super().__init__(request, *args, **kwargs) 12 | self.fields["username"].label = _("Email") 13 | self.fields["otp_token"] = forms.CharField( 14 | label=_("OTP Token"), 15 | required=True, 16 | widget=forms.TextInput(attrs={"autocomplete": "off"}), 17 | ) 18 | 19 | 20 | class UserCreationForm(forms.ModelForm): 21 | def save(self, commit=True): 22 | user = super().save(commit=False) 23 | user.set_password(self.cleaned_data["email"]) 24 | if commit: 25 | user.save() 26 | return user 27 | 28 | class Meta: 29 | model = User 30 | fields = ["email"] 31 | -------------------------------------------------------------------------------- /src/base/fields/encrypt.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | 4 | from base.utils.aes_chipher import AESCipher 5 | 6 | 7 | class EncryptedCharField(models.CharField): 8 | """ 9 | 암호화된 CharField 10 | 11 | 이 필드는 AES 암호화를 사용하여 데이터를 암호화하고 복호화합니다. 12 | 암호화 키는 Django 설정에서 USER_ENCRYPTION_KEY로 지정되어야 합니다. 13 | """ 14 | 15 | def __init__(self, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | self.aes_cipher = AESCipher(settings.USER_ENCRYPTION_KEY) 18 | 19 | def from_db_value(self, value, expression, connection): 20 | """데이터 조회 시 복호화""" 21 | if value is None: 22 | return value 23 | return self.to_python(value) 24 | 25 | def to_python(self, value): 26 | if value is None: 27 | return value 28 | if not isinstance(value, str) or len(value) < 24: 29 | return value 30 | return self.aes_cipher.decrypt(value) 31 | 32 | def get_prep_value(self, value): 33 | """데이터 저장 시 암호화""" 34 | if value is None: 35 | return value 36 | value_str = str(value) 37 | return self.aes_cipher.encrypt(value_str) 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 1단계: 베이스 이미지 설정 2 | FROM python:3.12-slim 3 | 4 | # 2단계: 환경 변수 설정 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONUNBUFFERED 1 7 | 8 | # 3단계: 작업 디렉토리 설정 9 | WORKDIR /app 10 | 11 | # 4단계: 시스템 의존성 설치 12 | RUN apt-get update && \ 13 | apt-get install -y --no-install-recommends build-essential libpq-dev && \ 14 | apt-get clean && \ 15 | rm -rf /var/lib/apt/lists/* 16 | 17 | # 5단계: Python 의존성 설치 18 | COPY requirements.txt . 19 | 20 | # pip를 최신 버전으로 업그레이드하고 requirements.txt에 명시된 패키지를 설치합니다. 21 | RUN pip install --upgrade pip && \ 22 | pip install --no-cache-dir -r requirements.txt 23 | 24 | # 6단계: 애플리케이션 코드 복사 25 | COPY src . 26 | 27 | # 7단계: (선택 사항) 정적 파일 수집 (필요한 경우) 28 | # RUN python manage.py collectstatic --noinput 29 | 30 | # 8단계: (선택 사항) 데이터베이스 마이그레이션 31 | # RUN python manage.py migrate 32 | 33 | # 9단계: 보안 강화를 위해 non-root 사용자 생성 및 사용 34 | RUN addgroup --system app && adduser --system --ingroup app app 35 | # 코드 디렉토리 소유권 변경 36 | RUN chown -R app:app /app 37 | # 사용자를 app으로 전환 38 | USER app 39 | 40 | # 10단계: 노출할 포트 설정 41 | EXPOSE 8000 42 | 43 | # 11단계: 컨테이너 실행 시 애플리케이션 서버 실행 44 | CMD ["gunicorn", "--bind", "0.0.0.0:8000", "conf.wsgi:application", "--workers", "3", "--threads", "2"] -------------------------------------------------------------------------------- /src/conf/settings/develop.py: -------------------------------------------------------------------------------- 1 | from conf.settings.base import * 2 | 3 | # CORS 4 | CORS_ORIGIN_ALLOW_ALL = False 5 | CORS_ALLOWED_ORIGINS = [ 6 | # 허용 오리진 추가 7 | ] 8 | CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") 9 | 10 | # Database 11 | DATABASES = { 12 | "default": { 13 | "ENGINE": "django.db.backends.postgresql", 14 | "NAME": os.environ.get("POSTGRES_DB", "postgres"), 15 | "USER": os.environ.get("POSTGRES_USER", "postgres"), 16 | "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "password"), 17 | "HOST": os.environ.get("POSTGRES_HOST", "postgres"), 18 | "PORT": os.environ.get("POSTGRES_PORT", "5432"), 19 | }, 20 | "replica": { 21 | "ENGINE": "django.db.backends.postgresql", 22 | "NAME": os.environ.get("POSTGRES_DB", "postgres"), 23 | "USER": os.environ.get("POSTGRES_USER", "postgres"), 24 | "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "password"), 25 | "HOST": os.environ.get("POSTGRES_REPLICA_HOST", "postgres"), 26 | "PORT": os.environ.get("POSTGRES_REPLICA_PORT", "5432"), 27 | }, 28 | } 29 | 30 | # LOGGING LEVEL 31 | LOGGING["loggers"]["django"]["level"] = "INFO" 32 | LOGGING["loggers"]["django.db.backends"]["level"] = "INFO" 33 | LOGGING["loggers"]["default"]["level"] = "INFO" 34 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | # 검사에서 무시할 디렉토리 및 파일 지정 3 | ignore=migrations, static, templates, venv, __pycache__, manage.py 4 | 5 | # Django 프로젝트와의 통합을 위한 플러그인 로드 6 | load-plugins=pylint_django 7 | 8 | # pylint 실행 시 Django 설정을 초기화하는 코드 9 | init-hook='import os; os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ezl_app_server.settings.local"); import django; django.setup()' 10 | 11 | # pylint의 캐시 기능 활성화 및 캐시 크기 설정 12 | persistent=yes 13 | cache-size=256 14 | 15 | [MESSAGES CONTROL] 16 | # 비활성화할 메시지 유형 (예: R0903 - 너무 단순한 클래스) 17 | disable= 18 | R0903, 19 | 20 | [FORMAT] 21 | # 한 줄의 최대 길이 설정 22 | max-line-length=88 23 | 24 | # 코드의 들여쓰기 스타일 설정 (공백 4칸) 25 | indent-string=' ' 26 | 27 | [DESIGN] 28 | # 함수의 최대 인자 수 29 | max-args=5 30 | 31 | # 클래스의 최대 속성 수 32 | max-attributes=10 33 | 34 | # 최대 분기 수 (복잡도 측정) 35 | max-branches=12 36 | 37 | # 함수 내 최대 명령문 수 38 | max-statements=50 39 | 40 | [TYPECHECK] 41 | # 타입 검사를 무시할 Django의 동적 속성 42 | generated-members= 43 | django.conf.settings.*, 44 | django.urls.*, 45 | django.db.models.fields.related.*, 46 | django.db.models.query_utils.Q 47 | 48 | # 타입 검사를 무시할 클래스들 49 | ignored-classes=QuerySet,Manager 50 | 51 | [BASIC] 52 | # 단일 문자 변수 이름 허용 53 | good-names=i,j,x,y 54 | 55 | [IMPORTS] 56 | # 서드파티 라이브러리 목록 57 | known-third-party=numpy,pandas 58 | 59 | [REPORTS] 60 | # pylint 리포트 설정 (기본적으로 설정을 유지) -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-boilerplate" 3 | version = "0.1.0" 4 | dependencies = [ 5 | "better-profanity>=0.7.0", 6 | "black>=25.1.0", 7 | "boto3>=1.40.6", 8 | "celery>=5.5.3", 9 | "django>=5.2.5", 10 | "django-cors-headers>=4.7.0", 11 | "django-debug-toolbar>=6.0.0", 12 | "django-extensions>=4.1", 13 | "django-filter>=25.1", 14 | "django-hosts>=7.0.0", 15 | "django-otp>=1.6.1", 16 | "django-redis>=6.0.0", 17 | "django-unfold>=0.63.0", 18 | "djangorestframework>=3.16.1", 19 | "djangorestframework-simplejwt>=5.5.1", 20 | "drf-nested-routers>=0.94.2", 21 | "drf-spectacular>=0.28.0", 22 | "firebase-admin>=7.1.0", 23 | "freezegun>=1.5.5", 24 | "google-auth>=2.40.3", 25 | "google-auth-oauthlib>=1.2.2", 26 | "gunicorn>=23.0.0", 27 | "psycopg2-binary>=2.9.10", 28 | "pycryptodome>=3.23.0", 29 | "python-dotenv>=1.1.1", 30 | "pytz>=2025.2", 31 | "qrcode>=8.2", 32 | "requests>=2.32.4", 33 | "sentry-sdk>=2.34.1", 34 | "uuid7>=0.1.0", 35 | "whitenoise>=6.9.0", 36 | ] 37 | 38 | 39 | [tool.black] 40 | line-length = 88 41 | target-version = ['py38', 'py39', 'py310'] 42 | skip-string-normalization = false 43 | extend-exclude = ''' 44 | /( 45 | migrations 46 | | static 47 | | templates 48 | | venv 49 | | \.venv 50 | | env 51 | )/ 52 | ''' 53 | -------------------------------------------------------------------------------- /src/apps/agreement/migrations/0002_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.7 on 2025-04-09 13:00 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ("agreement", "0001_initial"), 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name="useragreement", 20 | name="user", 21 | field=models.ForeignKey( 22 | on_delete=django.db.models.deletion.CASCADE, 23 | related_name="user_agreements", 24 | to=settings.AUTH_USER_MODEL, 25 | verbose_name="사용자", 26 | ), 27 | ), 28 | migrations.AddField( 29 | model_name="useragreementhistory", 30 | name="user_agreement", 31 | field=models.ForeignKey( 32 | on_delete=django.db.models.deletion.CASCADE, 33 | related_name="history", 34 | to="agreement.useragreement", 35 | verbose_name="사용자 약관 동의 정보", 36 | ), 37 | ), 38 | migrations.AlterUniqueTogether( 39 | name="useragreement", 40 | unique_together={("user", "agreement")}, 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | db: 5 | hostname: postgres 6 | image: postgres:latest 7 | container_name: postgres_db 8 | volumes: 9 | - postgres_data:/var/lib/postgresql/data 10 | environment: 11 | POSTGRES_DB: postgres 12 | POSTGRES_USER: postgres 13 | POSTGRES_PASSWORD: password 14 | expose: 15 | - "5432" 16 | restart: unless-stopped 17 | 18 | redis: 19 | hostname: redis 20 | image: redis:latest 21 | container_name: redis_cache 22 | volumes: 23 | - redis_data:/data 24 | expose: 25 | - "6379" 26 | restart: unless-stopped 27 | 28 | web: 29 | build: 30 | context: . 31 | dockerfile: Dockerfile 32 | container_name: django_web 33 | command: > 34 | sh -c "python manage.py migrate && 35 | gunicorn conf.wsgi:application --bind 0.0.0.0:8000" 36 | volumes: 37 | - ./src:/app 38 | environment: 39 | DJANGO_SETTINGS_MODULE: conf.settings.develop 40 | ports: 41 | - "8000:8000" 42 | depends_on: 43 | - db 44 | - redis 45 | restart: unless-stopped 46 | 47 | worker: 48 | build: 49 | context: . 50 | dockerfile: Dockerfile.celery 51 | container_name: celery_worker 52 | volumes: 53 | - ./src:/app 54 | environment: 55 | DJANGO_SETTINGS_MODULE: conf.settings.develop 56 | depends_on: 57 | - db 58 | - redis 59 | restart: unless-stopped 60 | 61 | volumes: 62 | postgres_data: 63 | redis_data: 64 | -------------------------------------------------------------------------------- /src/apps/file/migrations/0002_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.7 on 2025-04-08 05:57 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ("file", "0001_initial"), 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name="file", 20 | name="user", 21 | field=models.ForeignKey( 22 | on_delete=django.db.models.deletion.CASCADE, 23 | related_name="files", 24 | to=settings.AUTH_USER_MODEL, 25 | ), 26 | ), 27 | migrations.AddField( 28 | model_name="filedownloadhistory", 29 | name="file", 30 | field=models.ForeignKey( 31 | on_delete=django.db.models.deletion.CASCADE, 32 | related_name="download_history", 33 | to="file.file", 34 | ), 35 | ), 36 | migrations.AddField( 37 | model_name="filedownloadhistory", 38 | name="user", 39 | field=models.ForeignKey( 40 | on_delete=django.db.models.deletion.CASCADE, 41 | related_name="file_download_history", 42 | to=settings.AUTH_USER_MODEL, 43 | ), 44 | ), 45 | migrations.AddIndex( 46 | model_name="file", 47 | index=models.Index( 48 | fields=["content_type", "object_id"], name="file_content_cb60f8_idx" 49 | ), 50 | ), 51 | ] 52 | -------------------------------------------------------------------------------- /src/apps/common/management/commands/startapp.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.core.management import CommandError 5 | from django.core.management.templates import TemplateCommand 6 | 7 | 8 | class Command(TemplateCommand): 9 | help = ( 10 | "Creates a Django app directory structure for the given app name in the " 11 | "current directory or optionally in a new directory given by the first " 12 | "argument. The app name is the name of the app directory. The app name " 13 | "may contain only letters, numbers and underscores." 14 | ) 15 | missing_args_message = "Please provide an app name." 16 | 17 | def handle(self, **options): 18 | app_name = options.pop("name") 19 | 20 | self._create_app( 21 | app_name=app_name, 22 | template_name="app_template", 23 | **options, 24 | ) 25 | 26 | def _create_app(self, app_name, template_name, **options): 27 | target = f"apps/{app_name}" 28 | top_dir = os.path.abspath(os.path.expanduser(target)) 29 | try: 30 | self._make_dirs(top_dir) 31 | options["template"] = "file://" + str( 32 | settings.BASE_DIR / "apps" / "common" / "management" / template_name 33 | ) 34 | super().handle("app", app_name, target, **options) 35 | except CommandError: 36 | self.stderr.write(f'"{app_name}" app is already exists.') 37 | 38 | @staticmethod 39 | def _make_dirs(top_dir): 40 | try: 41 | os.makedirs(top_dir) 42 | except FileExistsError: 43 | raise CommandError("'%s' already exists" % top_dir) 44 | except OSError as e: 45 | raise CommandError(e) 46 | -------------------------------------------------------------------------------- /src/apps/device/v1/views.py: -------------------------------------------------------------------------------- 1 | from drf_spectacular.utils import extend_schema 2 | from rest_framework import viewsets, mixins 3 | from rest_framework.generics import get_object_or_404 4 | from rest_framework.permissions import IsAuthenticated 5 | 6 | from apps.device.models import Device 7 | from apps.device.v1.serializers import DeviceSerializer, PushTokenSerializer 8 | 9 | 10 | class DeviceViewSet(viewsets.GenericViewSet, mixins.CreateModelMixin): 11 | """디바이스""" 12 | 13 | serializer_class = DeviceSerializer 14 | permission_classes = [IsAuthenticated] 15 | lookup_field = "uuid" 16 | 17 | def perform_create(self, serializer): 18 | serializer.save(user=self.request.user) 19 | 20 | @extend_schema( 21 | request=DeviceSerializer, 22 | responses={ 23 | 201: DeviceSerializer, 24 | }, 25 | tags=["device"], 26 | summary="사용자 디바이스 생성", 27 | description=""" 28 | 로그인한 사용자의 디바이스를 생성합니다. 29 | """, 30 | ) 31 | def create(self, request, *args, **kwargs): 32 | return super().create(request, *args, **kwargs) 33 | 34 | 35 | class PushTokenViewSet(viewsets.GenericViewSet, mixins.CreateModelMixin): 36 | """푸시 토큰""" 37 | 38 | serializer_class = PushTokenSerializer 39 | permission_classes = [IsAuthenticated] 40 | 41 | def perform_create(self, serializer): 42 | device = get_object_or_404(Device, uuid=self.kwargs["device_uuid"]) 43 | serializer.save(device=device) 44 | 45 | @extend_schema( 46 | request=PushTokenSerializer, 47 | responses={ 48 | 201: PushTokenSerializer, 49 | }, 50 | tags=["device"], 51 | summary="사용자 푸시 토큰 생성", 52 | description=""" 53 | 로그인한 사용자의 푸시 토큰을 생성합니다. 54 | """, 55 | ) 56 | def create(self, request, *args, **kwargs): 57 | return super().create(request, *args, **kwargs) 58 | -------------------------------------------------------------------------------- /src/apps/cms/migrations/0002_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.7 on 2025-04-08 05:57 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ("cms", "0001_initial"), 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name="event", 20 | name="author", 21 | field=models.ForeignKey( 22 | help_text="이벤트 작성자", 23 | on_delete=django.db.models.deletion.CASCADE, 24 | to=settings.AUTH_USER_MODEL, 25 | verbose_name="작성자", 26 | ), 27 | ), 28 | migrations.AddField( 29 | model_name="faq", 30 | name="author", 31 | field=models.ForeignKey( 32 | help_text="FAQ 작성자", 33 | on_delete=django.db.models.deletion.CASCADE, 34 | to=settings.AUTH_USER_MODEL, 35 | verbose_name="작성자", 36 | ), 37 | ), 38 | migrations.AddField( 39 | model_name="faq", 40 | name="category", 41 | field=models.ForeignKey( 42 | help_text="FAQ 카테고리", 43 | on_delete=django.db.models.deletion.CASCADE, 44 | to="cms.faqcategory", 45 | verbose_name="카테고리", 46 | ), 47 | ), 48 | migrations.AddField( 49 | model_name="notice", 50 | name="author", 51 | field=models.ForeignKey( 52 | help_text="공지 작성자", 53 | on_delete=django.db.models.deletion.CASCADE, 54 | to=settings.AUTH_USER_MODEL, 55 | verbose_name="작성자", 56 | ), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /src/apps/short_url/v1/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from drf_spectacular.utils import extend_schema 3 | from rest_framework import viewsets, mixins, generics, permissions 4 | from rest_framework.generics import get_object_or_404 5 | 6 | from apps.short_url.models import ShortUrl 7 | from apps.short_url.v1.serializers import ShortUrlSerializer, ShortUrlRedirectSerializer 8 | from apps.short_url.v1.utils import key_to_id 9 | 10 | 11 | class ShortUrlViewSet( 12 | viewsets.GenericViewSet, 13 | mixins.CreateModelMixin, 14 | ): 15 | """단축 URL 뷰셋""" 16 | 17 | serializer_class = ShortUrlSerializer 18 | permission_classes = [permissions.IsAuthenticated] 19 | 20 | @extend_schema( 21 | request=ShortUrlSerializer, 22 | responses={ 23 | 201: ShortUrlSerializer, 24 | }, 25 | tags=["short-url"], 26 | summary="단축URL 생성", 27 | description=""" 28 | 단축 URL을 생성합니다. 29 | 4자리 랜덤 키와 id를 문자열로 변경한 값을 결합해 ShortKey를 생성합니다. 30 | """, 31 | ) 32 | def create(self, request, *args, **kwargs): 33 | return super().create(request, *args, **kwargs) 34 | 35 | 36 | class ShortUrlRedirectView(generics.RetrieveAPIView): 37 | """단축 URL 리다이렉트 뷰""" 38 | 39 | serializer_class = ShortUrlRedirectSerializer 40 | permission_classes = [] 41 | authentication_classes = [] 42 | 43 | @extend_schema( 44 | responses={ 45 | 200: ShortUrlRedirectSerializer, 46 | }, 47 | tags=["short-url"], 48 | summary="단축URL 리다이렉트", 49 | description=""" 50 | 단축 URL을 리다이렉트합니다. 51 | """, 52 | ) 53 | def retrieve(self, request, *args, **kwargs): 54 | short_url_id = key_to_id(self.kwargs.get("short_key")[2:-2]) 55 | instance = get_object_or_404(ShortUrl, id=short_url_id) 56 | serializer = self.get_serializer(instance) 57 | serializer.save() 58 | return render(request, "short_url/redirect.html", serializer.data) 59 | -------------------------------------------------------------------------------- /src/conf/routers.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import transaction 3 | 4 | 5 | class DefaultRouter: 6 | """데이터베이스 라우터""" 7 | 8 | def _get_model_database(self, model): 9 | """모델의 Meta 클래스에서 database 속성 값 조회""" 10 | 11 | return getattr(model._meta, "database", None) 12 | 13 | def db_for_read(self, model, **hints): 14 | """읽기에서 사용할 데이터베이스""" 15 | 16 | # 1. 실제 지정한 데이터베이스로 연결 17 | model_database = self._get_model_database(model) 18 | if model_database: 19 | return model_database 20 | 21 | # 2. "default" 트랜잭션 블록 내부는 기본 데이터베이스 연결 22 | if ( 23 | not transaction.get_autocommit() 24 | and transaction.get_connection("default").in_atomic_block 25 | ): 26 | return "default" 27 | 28 | # 3. 그 외, "replica" 데이터베이스 연결 29 | return "replica" 30 | 31 | def db_for_write(self, model, **hints): 32 | """쓰기에서 사용할 데이터베이스""" 33 | 34 | # 1. 실제 지정한 데이터베이스로 연결 35 | model_database = self._get_model_database(model) 36 | if model_database: 37 | return model_database 38 | 39 | return "default" 40 | 41 | def allow_relation(self, obj1, obj2, **hints): 42 | """연결 허용 여부 확인""" 43 | 44 | db_obj1 = self.db_for_write(obj1.__class__, **hints) 45 | db_obj2 = self.db_for_write(obj2.__class__, **hints) 46 | 47 | # 1. 같은 데이터베이스에 연결된 경우 48 | if db_obj1 and db_obj2: 49 | return db_obj1 == db_obj2 50 | 51 | # 2. 서로 다른 데이터베이스에 연결된 경우 52 | return None 53 | 54 | def allow_migrate(self, db, app_label, model_name=None, **hints): 55 | """마이그레이션 허용 여부 확인""" 56 | 57 | migration_mapping = settings.DATABASE_APPS_MAPPING_FOR_MIGRATIONS 58 | mapped_db = migration_mapping.get(app_label) 59 | 60 | # 1. 실제 지정한 데이터베이스로 연결 61 | if mapped_db: 62 | return db == mapped_db 63 | 64 | # 2. "default" 트랜잭션 블록 내부는 기본 데이터베이스 연결 65 | return db == "default" 66 | -------------------------------------------------------------------------------- /src/conf/urls/api.py: -------------------------------------------------------------------------------- 1 | from debug_toolbar.toolbar import debug_toolbar_urls 2 | from django.conf import settings 3 | from django.contrib import admin 4 | from django.http import HttpResponse 5 | from django.urls import path, include 6 | from drf_spectacular.views import ( 7 | SpectacularJSONAPIView, 8 | SpectacularRedocView, 9 | SpectacularSwaggerView, 10 | ) 11 | 12 | from base.enums.base import DjangoEnvironment 13 | 14 | urlpatterns = [ 15 | path("v1/", include("apps.account.v1.urls")), 16 | path("v1/", include("apps.user.v1.urls")), 17 | path("v1/", include("apps.device.v1.urls")), 18 | path("v1/", include("apps.short_url.v1.urls")), 19 | path("v1/", include("apps.feed.v1.urls")), 20 | path("v1/", include("apps.file.v1.urls")), 21 | path("v1/", include("apps.agreement.v1.urls")), 22 | path("v1/", include("apps.cms.v1.urls")), 23 | path("v1/", include("apps.game.v1.urls")), 24 | path("_health/", lambda request: HttpResponse()), 25 | ] 26 | 27 | # 디버깅 모드 전환 28 | if settings.DEBUG: 29 | urlpatterns += debug_toolbar_urls() 30 | 31 | # 로컬 환경에서만 admin 페이지 접근 가능 32 | if settings.DJANGO_ENVIRONMENT == DjangoEnvironment.LOCAL.value: 33 | from apps.user.v1 import views as user_views 34 | 35 | from apps.user.forms import OTPAuthenticationForm 36 | 37 | admin.site.login_form = OTPAuthenticationForm 38 | 39 | urlpatterns += [ 40 | path("admin/", admin.site.urls), 41 | path( 42 | "setup-otp///", user_views.setup_otp_view, name="setup_otp" 43 | ), 44 | ] 45 | 46 | # 로컬, 개발, 스테이지 환경에서만 API 문서 접근 가능 47 | if settings.DJANGO_ENVIRONMENT in [ 48 | DjangoEnvironment.LOCAL.value, 49 | DjangoEnvironment.DEVELOP.value, 50 | ]: 51 | urlpatterns += [ 52 | path( 53 | "openapi.json/", 54 | SpectacularJSONAPIView.as_view(patterns=urlpatterns), 55 | name="schema", 56 | ), 57 | path("swagger/", SpectacularSwaggerView.as_view(), name="swagger-ui"), 58 | path("redoc/", SpectacularRedocView.as_view(), name="redoc"), 59 | ] 60 | -------------------------------------------------------------------------------- /src/apps/common/caches.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | from functools import wraps 4 | 5 | from django.core.cache import cache 6 | from rest_framework.response import Response 7 | 8 | 9 | def get_cache_version_key(cls): 10 | return f"cache_version:{cls.__module__}.{cls.__name__}" 11 | 12 | 13 | def invalidate_cache(cls): 14 | version_key = get_cache_version_key(cls) 15 | try: 16 | cache.incr(version_key) 17 | except ValueError: 18 | cache.set(version_key, 1, timeout=None) # 영구 보존 19 | 20 | 21 | def cache_action(action_type): 22 | def decorator(func): 23 | @wraps(func) 24 | def wrapper(view, request, *args, **kwargs): 25 | version_key = get_cache_version_key(view.__class__) 26 | version = cache.get(version_key, 1) 27 | 28 | if action_type == "list": 29 | # 쿼리 파라미터 해시 생성 30 | query_params = request.query_params.items() 31 | sorted_params = sorted(query_params) 32 | params_string = json.dumps(sorted_params) 33 | params_hash = hashlib.md5(params_string.encode("utf-8")).hexdigest() 34 | unique_part = params_hash 35 | elif action_type == "retrieve": 36 | # lookup_value 추출 37 | lookup_value = kwargs.get(view.lookup_field, None) 38 | if not lookup_value: 39 | return func(view, request, *args, **kwargs) 40 | unique_part = str(lookup_value) 41 | else: 42 | raise ValueError(f"Unsupported action_type: {action_type}") 43 | 44 | cache_key = f"{version}:{view.get_cache_key(unique_part=unique_part)}" 45 | cached_data = cache.get(cache_key) 46 | if cached_data is not None: 47 | return Response(cached_data) 48 | 49 | response = func(view, request, *args, **kwargs) 50 | 51 | if response.status_code == 200: 52 | cache.set(cache_key, response.data, timeout=60 * 60) # 1시간 캐시 53 | 54 | return response 55 | 56 | return wrapper 57 | 58 | return decorator 59 | -------------------------------------------------------------------------------- /src/apps/account/v1/services.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import hashlib 3 | import uuid 4 | 5 | from django.conf import settings 6 | from django.core.cache import cache 7 | from django.core.mail import send_mail 8 | from django.template.loader import render_to_string 9 | from django.utils.safestring import mark_safe 10 | 11 | 12 | class EmailTemplate(enum.Enum): 13 | """이메일 템플릿""" 14 | 15 | SIGNUP = ( 16 | "회원 가입 인증", 17 | settings.SIGNUP_CONFIRM_URL, 18 | "account/signup.html", 19 | ) 20 | RESET_PASSWORD = ( 21 | "패스워드 재설정", 22 | settings.RESET_PASSWORD_URL, 23 | "account/reset_password.html", 24 | ) 25 | 26 | 27 | def send_email_verification_token(email: str, template_type: EmailTemplate) -> dict: 28 | """이메일 검증 토큰 발송""" 29 | if not email: 30 | return {} 31 | 32 | # 검증 키 생성 및 설정 33 | token = uuid.uuid4().hex 34 | django_env = settings.DJANGO_ENVIRONMENT 35 | hash_input = (email + settings.EMAIL_VERIFICATION_HASH_SALT).encode("utf-8") 36 | hashed_email = hashlib.md5(hash_input).hexdigest() 37 | cache_key = f"email_verification:{django_env}:{hashed_email}" 38 | cache.set( 39 | cache_key, 40 | {"token": token, "email": email}, 41 | timeout=settings.EMAIL_VERIFICATION_TIMEOUT, 42 | ) 43 | 44 | # 이메일 내용 생성 45 | subject, base_url, template_path = template_type.value 46 | verification_url = f"{base_url}?hashed_email={hashed_email}&token={token}" 47 | context = {"url": mark_safe(verification_url)} 48 | html_message = render_to_string(template_path, context) 49 | 50 | # 이메일 발송 51 | send_mail( 52 | subject=subject, 53 | message="", 54 | html_message=html_message, 55 | from_email=settings.DEFAULT_FROM_EMAIL, 56 | recipient_list=[email], 57 | ) 58 | return {"hashed_email": hashed_email, "token": token} 59 | 60 | 61 | def get_cached_email_verification_data(hashed_email: str) -> dict: 62 | """캐시된 이메일 검증 데이터 조회""" 63 | if not hashed_email: 64 | return {} 65 | django_env = settings.DJANGO_ENVIRONMENT 66 | cache_key = f"email_verification:{django_env}:{hashed_email}" 67 | return cache.get(cache_key, {}) 68 | -------------------------------------------------------------------------------- /src/apps/device/v1/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from apps.device.models import Device, PushToken 4 | from base.enums.errors import ( 5 | E007_DEVICE_UUID_REQUIRED, 6 | E007_DEVICE_ALREADY_REGISTERED, 7 | E007_PUSH_TOKEN_REQUIRED, 8 | E007_PUSH_TOKEN_ALREADY_REGISTERED, 9 | ) 10 | 11 | 12 | class DeviceSerializer(serializers.ModelSerializer): 13 | """ 14 | 디바이스 시리얼라이저: 15 | 디바이스 UUID 존재 여부 확인 및 생성 16 | """ 17 | 18 | def validate_uuid(self, attr): 19 | """UUID 유효성 검사""" 20 | if not attr: 21 | raise serializers.ValidationError(E007_DEVICE_UUID_REQUIRED) 22 | elif Device.objects.filter( 23 | uuid=attr, 24 | user=self.context["request"].user, 25 | ).exists(): 26 | raise serializers.ValidationError(E007_DEVICE_ALREADY_REGISTERED) 27 | return attr 28 | 29 | def create(self, validated_data): 30 | instance = super().create(validated_data) 31 | # TODO: AWS SNS 의 구독 정보 업데이트 32 | return instance 33 | 34 | class Meta: 35 | model = Device 36 | fields = [ 37 | "id", 38 | "uuid", 39 | "platform", 40 | "created_at", 41 | "updated_at", 42 | ] 43 | 44 | 45 | class PushTokenSerializer(serializers.ModelSerializer): 46 | """ 47 | 푸시 토큰 시리얼라이저: 48 | 사용자별 토큰 고유 여부 확인 후 등록 49 | 추후 AWS SNS Token 등록하여 푸시 활용 50 | """ 51 | 52 | def validate_token(self, attr): 53 | """토큰 유효성 검사""" 54 | if not attr: 55 | raise serializers.ValidationError(E007_PUSH_TOKEN_REQUIRED) 56 | elif PushToken.objects.filter( 57 | token=attr, 58 | device__user=self.context["request"].user, 59 | ).exists(): 60 | raise serializers.ValidationError(E007_PUSH_TOKEN_ALREADY_REGISTERED) 61 | return attr 62 | 63 | def create(self, validated_data): 64 | """생성""" 65 | instance = super().create(validated_data) 66 | # TODO: 푸시 토큰 생성 후 endpoint arn 생성 67 | return instance 68 | 69 | class Meta: 70 | model = PushToken 71 | fields = [ 72 | "id", 73 | "token", 74 | ] 75 | -------------------------------------------------------------------------------- /src/apps/game/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | 4 | 5 | class AttendanceCheck(models.Model): 6 | """사용자 출석 체크 이력""" 7 | 8 | user = models.ForeignKey( 9 | "user.User", 10 | on_delete=models.CASCADE, 11 | related_name="attendance_checks", 12 | verbose_name="사용자", 13 | ) 14 | check_in_date = models.DateField( 15 | verbose_name="참여 일자", 16 | db_index=True, 17 | ) 18 | consecutive_days = models.PositiveIntegerField( 19 | verbose_name="연속 일수", 20 | default=0, 21 | ) 22 | created_at = models.DateTimeField( 23 | auto_now_add=True, 24 | verbose_name="생성 일시", 25 | ) 26 | 27 | def save(self, *args, **kwargs): 28 | if not self.pk: 29 | # 연속 일수에 해당하는 포인트 지급 30 | reward_point = settings.ATTENDANCE_CHECK_REWARD_POINTS[ 31 | self.consecutive_days 32 | ] 33 | GamePoint.objects.create( 34 | user=self.user, 35 | point=reward_point, 36 | reason=PointReason.ATTENDANCE_CHECK, 37 | ) 38 | return super().save(*args, **kwargs) 39 | 40 | class Meta: 41 | db_table = "attendance_check" 42 | verbose_name = "출석 체크 이력" 43 | verbose_name_plural = verbose_name 44 | unique_together = ["user", "check_in_date"] 45 | 46 | 47 | class PointReason(models.IntegerChoices): 48 | """포인트 발급 사유""" 49 | 50 | ATTENDANCE_CHECK = 1, "출석 체크" 51 | COIN_FLIP = 2, "동전 뒤집기" 52 | 53 | 54 | class GamePoint(models.Model): 55 | """게임 포인트""" 56 | 57 | user = models.ForeignKey( 58 | "user.User", 59 | on_delete=models.CASCADE, 60 | related_name="game_points", 61 | verbose_name="사용자", 62 | ) 63 | point = models.IntegerField( 64 | verbose_name="포인트", 65 | help_text="지급/차감 포인트", 66 | ) 67 | reason = models.PositiveSmallIntegerField( 68 | choices=PointReason.choices, 69 | verbose_name="사유", 70 | ) 71 | created_at = models.DateTimeField( 72 | auto_now_add=True, 73 | verbose_name="생성 일시", 74 | ) 75 | 76 | class Meta: 77 | db_table = "game_point" 78 | verbose_name = "게임 포인트" 79 | verbose_name_plural = verbose_name 80 | -------------------------------------------------------------------------------- /src/apps/game/v1/serializers.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.conf import settings 4 | from django.db import IntegrityError 5 | from django.utils import timezone 6 | from rest_framework import serializers 7 | 8 | from apps.game.models import AttendanceCheck 9 | 10 | 11 | class AttendanceCheckSerializer(serializers.ModelSerializer): 12 | """ 13 | 출석 체크 시리얼라이저: 14 | 마지막 출석 체크 이력을 조회해서 현재의 상태를 확인 15 | 그리고 연속 참여 여부를 확인해서 포인트를 지급 16 | """ 17 | 18 | consecutive_days = serializers.IntegerField( 19 | help_text="연속 일수", required=False, default=0 20 | ) 21 | 22 | def to_internal_value(self, data): 23 | # self.instance 의 check_in_date 가 어제가 아니면 0 으로 초기화 24 | # [Why] 25 | # Q. 왜 어제 체크인 날짜가 아닌 경우 0으로 초기화 하는가? 26 | # A. 어제 체크인 날짜가 아닌 경우, 연속 출석이라고 할 수 없음 27 | # 출석 체크는 하루에 한 번만 가능 28 | data["consecutive_days"] = 0 29 | if ( 30 | self.instance 31 | and self.instance.check_in_date == timezone.now().date() - timedelta(days=1) 32 | and self.instance.consecutive_days 33 | < len(settings.ATTENDANCE_CHECK_REWARD_POINTS) - 1 34 | ): 35 | # 어제 출석체크 한 경우 연속 일수를 1 증가 36 | data["consecutive_days"] = self.instance.consecutive_days + 1 37 | return super().to_internal_value(data) 38 | 39 | def save(self, **kwargs): 40 | try: 41 | # 오늘 출석 체크 이력을 생성하거나 존재한다면 조회 42 | user = self.context["request"].user 43 | self.instance, _ = AttendanceCheck.objects.get_or_create( 44 | user=user, 45 | check_in_date=timezone.now().date(), 46 | defaults={ 47 | "consecutive_days": self.validated_data.get("consecutive_days") 48 | }, 49 | ) 50 | except IntegrityError: 51 | raise serializers.ValidationError("Attendance check already exists") 52 | return self.instance 53 | 54 | class Meta: 55 | model = AttendanceCheck 56 | fields = [ 57 | "id", 58 | "check_in_date", 59 | "consecutive_days", 60 | "created_at", 61 | ] 62 | read_only_fields = [ 63 | "id", 64 | "check_in_date", 65 | "created_at", 66 | ] 67 | -------------------------------------------------------------------------------- /src/apps/cms/v1/serializers.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from rest_framework import serializers 3 | 4 | from apps.cms.models import Notice, Event, Faq, FaqCategory 5 | 6 | 7 | class NoticeSerializer(serializers.ModelSerializer): 8 | """ 9 | 공지사항 시리얼라이저: 10 | 공지사항 리스트 및 상세 조회 11 | """ 12 | 13 | class Meta: 14 | model = Notice 15 | fields = [ 16 | "uuid", 17 | "title", 18 | "content", 19 | "published_at", 20 | "start_at", 21 | "end_at", 22 | "created_at", 23 | "updated_at", 24 | ] 25 | 26 | 27 | class EventSerializer(serializers.ModelSerializer): 28 | """ 29 | 이벤트 시리얼라이저: 30 | 이벤트 리스트 및 상세 조회 31 | 종료된 이벤트 여부 표시 32 | """ 33 | 34 | is_event_ended = serializers.SerializerMethodField( 35 | method_name="get_is_event_ended", 36 | help_text="이벤트 종료 여부", 37 | ) 38 | 39 | def get_is_event_ended(self, obj): 40 | """이벤트 종료 여부 확인""" 41 | return obj.event_end_at <= timezone.now() if obj.event_end_at else False 42 | 43 | class Meta: 44 | model = Event 45 | fields = [ 46 | "uuid", 47 | "title", 48 | "content", 49 | "published_at", 50 | "start_at", 51 | "end_at", 52 | "event_start_at", 53 | "event_end_at", 54 | "is_event_ended", 55 | "created_at", 56 | "updated_at", 57 | ] 58 | 59 | 60 | class FaqCategorySerializer(serializers.ModelSerializer): 61 | """ 62 | FAQ 카테고리 시리얼라이저: 63 | FAQ 리스트 및 상세 조회 시 카테고리 정보 64 | """ 65 | 66 | class Meta: 67 | model = FaqCategory 68 | fields = [ 69 | "uuid", 70 | "name", 71 | "created_at", 72 | "updated_at", 73 | ] 74 | 75 | 76 | class FaqSerializer(serializers.ModelSerializer): 77 | """ 78 | FAQ 시리얼라이저: 79 | FAQ 리스트 및 상세 조회 80 | """ 81 | 82 | category = FaqCategorySerializer(help_text="FAQ 카테고리") 83 | 84 | class Meta: 85 | model = Faq 86 | fields = [ 87 | "uuid", 88 | "category", 89 | "title", 90 | "content", 91 | "published_at", 92 | "created_at", 93 | "updated_at", 94 | ] 95 | -------------------------------------------------------------------------------- /src/apps/short_url/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class ShortUrl(models.Model): 5 | """단축 URL""" 6 | 7 | random_key = models.CharField( 8 | max_length=4, 9 | verbose_name="랜덤 키", 10 | ) 11 | ios_deep_link = models.CharField( 12 | max_length=255, 13 | verbose_name="iOS 딥링크", 14 | null=True, 15 | blank=True, 16 | ) 17 | ios_fallback_url = models.URLField( 18 | verbose_name="iOS 폴백 URL", 19 | null=True, 20 | blank=True, 21 | ) 22 | android_deep_link = models.CharField( 23 | max_length=255, 24 | verbose_name="안드로이드 딥링크", 25 | null=True, 26 | blank=True, 27 | ) 28 | android_fallback_url = models.URLField( 29 | verbose_name="안드로이드 폴백 URL", 30 | null=True, 31 | blank=True, 32 | ) 33 | default_fallback_url = models.URLField( 34 | verbose_name="기본 폴백 URL", 35 | ) 36 | hashed_value = models.CharField( 37 | max_length=64, 38 | verbose_name="해시값", 39 | db_index=True, 40 | ) 41 | og_tag = models.JSONField( 42 | null=True, 43 | blank=True, 44 | verbose_name="OG 태그", 45 | ) 46 | created_at = models.DateTimeField( 47 | auto_now_add=True, 48 | verbose_name="생성 일시", 49 | ) 50 | 51 | class Meta: 52 | verbose_name = "단축 URL" 53 | verbose_name_plural = "단축 URL" 54 | db_table = "short_url" 55 | 56 | 57 | class ShortUrlVisit(models.Model): 58 | """단축 URL 방문""" 59 | 60 | short_url = models.ForeignKey( 61 | ShortUrl, 62 | on_delete=models.CASCADE, 63 | verbose_name="단축 URL", 64 | ) 65 | referrer = models.CharField( 66 | max_length=255, 67 | verbose_name="레퍼러", 68 | null=True, 69 | blank=True, 70 | ) 71 | user_agent = models.TextField( 72 | verbose_name="사용자 에이전트", 73 | null=True, 74 | blank=True, 75 | ) 76 | ip_address = models.GenericIPAddressField( 77 | verbose_name="IP 주소", 78 | null=True, 79 | blank=True, 80 | ) 81 | created_at = models.DateTimeField( 82 | auto_now_add=True, 83 | verbose_name="생성 일시", 84 | ) 85 | 86 | class Meta: 87 | verbose_name = "단축 URL 방문" 88 | verbose_name_plural = "단축 URL 방문" 89 | db_table = "short_url_visit" 90 | -------------------------------------------------------------------------------- /src/apps/short_url/templates/short_url/redirect.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% if og_tag %} 9 | {% for key, value in og_tag.items %} 10 | 11 | {% endfor %} 12 | {% endif %} 13 | 14 | 15 | 16 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/apps/device/utils.py: -------------------------------------------------------------------------------- 1 | from firebase_admin import messaging 2 | 3 | from apps.device.models import PushToken 4 | 5 | 6 | def send_test_push_to_all(message: dict, data: dict = None): 7 | """전체 사용자 테스트 푸시 발송""" 8 | 9 | token_qs = ( 10 | PushToken.objects.filter(is_valid=True) 11 | .values_list("token", flat=True) 12 | .iterator() 13 | ) 14 | 15 | # 전체 결과 집계를 위한 변수 16 | total_success_count = 0 17 | total_failure_count = 0 18 | total_invalid_tokens = [] 19 | 20 | # 500개씩 담을 배치 리스트 21 | batch_tokens = [] 22 | 23 | for token in token_qs: 24 | batch_tokens.append(token) 25 | # 배치가 500개가 되면 발송 26 | if len(batch_tokens) == 500: 27 | success, failure, invalid = send_test_push(batch_tokens, message, data) 28 | 29 | # 결과 집계 30 | total_success_count += success 31 | total_failure_count += failure 32 | total_invalid_tokens.extend(invalid) 33 | 34 | # 배치 리스트 초기화 35 | batch_tokens = [] 36 | else: 37 | # 남아있는 토큰들 발송 38 | if batch_tokens: 39 | success, failure, invalid = send_test_push(batch_tokens, message, data) 40 | 41 | # 결과 집계 42 | total_success_count += success 43 | total_failure_count += failure 44 | total_invalid_tokens.extend(invalid) 45 | 46 | return total_success_count, total_failure_count, total_invalid_tokens 47 | 48 | 49 | def send_test_push(tokens: list, message: dict, data: dict = None): 50 | """테스트 푸시 발송""" 51 | 52 | messages = [] 53 | for token in tokens: 54 | message_data = {"token": token} 55 | if message: 56 | message_data["notification"] = messaging.Notification(**message) 57 | if data: 58 | message_data["data"] = data 59 | messages.append(messaging.Message(**message_data)) 60 | 61 | # 푸시 발송 62 | response = messaging.send_each(messages) 63 | 64 | # 결과 수집 65 | success_count = 0 66 | failure_count = 0 67 | invalid_tokens = [] 68 | for i, resp in enumerate(response.responses): 69 | if resp.success: 70 | success_count += 1 71 | else: 72 | if resp.exception.code == "messaging/invalid-registration-token": 73 | invalid_tokens.append(tokens[i]) 74 | failure_count += 1 75 | 76 | return success_count, failure_count, invalid_tokens 77 | -------------------------------------------------------------------------------- /src/base/utils/aes_chipher.py: -------------------------------------------------------------------------------- 1 | from Crypto.Cipher import AES 2 | from Crypto.Util.Padding import pad, unpad 3 | import base64 4 | import os 5 | 6 | 7 | class AESCipher: 8 | """ 9 | AES-256 암호화 및 복호화를 수행하는 클래스. 10 | CBC 모드를 사용하며, IV를 암호문 앞에 붙여 Base64로 인코딩합니다. 11 | """ 12 | 13 | def __init__(self, key: str): 14 | """ 15 | 초기화 메서드. 16 | 17 | Args: 18 | key (str): 32바이트 길이의 AES-256 암호화 키 (문자열). 19 | Raises: 20 | ValueError: 키 길이가 32바이트가 아닐 경우 발생합니다. 21 | """ 22 | self.key = key.encode("utf-8") 23 | if len(self.key) != 32: 24 | raise ValueError("AES-256 키는 반드시 32바이트여야 합니다.") 25 | self.block_size = AES.block_size # AES 블록 크기는 16바이트 26 | 27 | def encrypt(self, plaintext: str) -> str: 28 | """ 29 | 주어진 평문을 AES-256으로 암호화합니다. 30 | 31 | Args: 32 | plaintext (str): 암호화할 문자열. 33 | 34 | Returns: 35 | str: Base64로 인코딩된 암호문 (IV 포함). 36 | """ 37 | plaintext_bytes = plaintext.encode("utf-8") 38 | 39 | # CBC 모드를 위한 랜덤 IV 생성 (16바이트) 40 | iv = os.urandom(self.block_size) 41 | 42 | # AES 암호화 객체 생성 (CBC 모드) 43 | cipher = AES.new(self.key, AES.MODE_CBC, iv) 44 | 45 | # 평문 패딩 및 암호화 46 | padded_plaintext = pad(plaintext_bytes, self.block_size) 47 | ciphertext_bytes = cipher.encrypt(padded_plaintext) 48 | 49 | # IV와 암호문 결합 후 Base64 인코딩 50 | encrypted_data = iv + ciphertext_bytes 51 | return base64.b64encode(encrypted_data).decode("utf-8") 52 | 53 | def decrypt(self, encrypted_text: str) -> str: 54 | """ 55 | Base64 인코딩된 암호문을 AES-256으로 복호화합니다. 56 | 57 | Args: 58 | encrypted_text (str): Base64로 인코딩된 암호문 (IV 포함). 59 | 60 | Returns: 61 | str: 복호화된 원본 문자열. 62 | """ 63 | # Base64 디코딩 64 | encrypted_data = base64.b64decode(encrypted_text) 65 | 66 | # IV와 암호문 분리 67 | iv = encrypted_data[: self.block_size] 68 | ciphertext_bytes = encrypted_data[self.block_size :] 69 | 70 | # AES 복호화 객체 생성 (CBC 모드) 71 | cipher = AES.new(self.key, AES.MODE_CBC, iv) 72 | 73 | # 복호화 및 패딩 제거 74 | decrypted_padded_bytes = cipher.decrypt(ciphertext_bytes) 75 | decrypted_bytes = unpad(decrypted_padded_bytes, self.block_size) 76 | 77 | # UTF-8 문자열로 디코딩하여 반환 78 | return decrypted_bytes.decode("utf-8") 79 | -------------------------------------------------------------------------------- /src/conf/caches.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import threading 3 | 4 | from django.core.cache.backends.redis import RedisCache as DjangoRedisCache 5 | from django.utils import timezone 6 | 7 | 8 | class RedisCache(DjangoRedisCache): 9 | """Redis Cache""" 10 | 11 | def _set_value( 12 | self, 13 | key, 14 | value_class, 15 | value_kwargs=None, 16 | timeout=60 * 60, 17 | smoothly_timeout=60 * 10, 18 | ): 19 | """캐시 데이터 저장""" 20 | value = value_class(**value_kwargs) 21 | self.set( 22 | key, 23 | { 24 | "value": value, 25 | "smoothly_datetime": ( 26 | timezone.localtime() + datetime.timedelta(seconds=smoothly_timeout) 27 | ).strftime("%Y-%m-%d %H:%M:%S"), 28 | }, 29 | timeout, 30 | ) 31 | return value 32 | 33 | def smooth( 34 | self, 35 | key, 36 | value_class, 37 | value_kwargs=None, 38 | timeout=60 * 60, 39 | smoothly_timeout=60 * 10, 40 | ): 41 | """스무스한 데이터 조회 및 설정""" 42 | # 데이터 조회 43 | data = self.get(key) 44 | if data is None or type(data) != dict: 45 | data = {} 46 | 47 | # 데이터 조회 48 | value = data.get("value") 49 | smoothly_datetime = ( 50 | datetime.datetime.strptime( 51 | data.get("smoothly_datetime"), "%Y-%m-%d %H:%M:%S" 52 | ) 53 | if data.get("smoothly_datetime") 54 | else None 55 | ) 56 | 57 | # 1. 유효한 데이터의 경우 그대로 반환 58 | if smoothly_datetime is not None and smoothly_datetime >= timezone.localtime(): 59 | return value 60 | args = [key, value_class, value_kwargs, timeout, smoothly_timeout] 61 | # 2. 데이터는 있지만 유효하지 않은 경우 62 | if smoothly_datetime is not None and smoothly_datetime < timezone.localtime(): 63 | # 비동기 처리되는 동안 기존 데이터를 반환 64 | self._set_value( 65 | key=key, 66 | value_class=lambda v: v, 67 | value_kwargs={"v": value}, 68 | timeout=timeout, 69 | smoothly_timeout=smoothly_timeout, 70 | ) 71 | # 비동기 72 | threading.Thread(target=self._set_value, args=args).start() 73 | # 3. 데이터가 없는 경우 74 | else: 75 | # 동기 76 | value = self._set_value(*args) 77 | return value 78 | -------------------------------------------------------------------------------- /src/apps/device/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class DevicePlatform(models.IntegerChoices): 5 | """디바이스 플랫폼""" 6 | 7 | ANDROID = 1, "Android" 8 | IOS = 2, "iOS" 9 | WEB = 3, "Web" 10 | 11 | 12 | class Device(models.Model): 13 | """디바이스""" 14 | 15 | user = models.ForeignKey( 16 | "user.User", 17 | on_delete=models.CASCADE, 18 | related_name="devices", 19 | verbose_name="사용자", 20 | ) 21 | # [Why] 22 | # Q. 디바이스 UUID를 unique하게 설정한 이유는? 23 | # A. UUID는 디바이스를 고유하게 식별하기 위한 값이기 때문 24 | uuid = models.UUIDField( 25 | unique=True, 26 | verbose_name="UUID", 27 | ) 28 | platform = models.CharField( 29 | max_length=50, 30 | choices=DevicePlatform.choices, 31 | default=DevicePlatform.WEB, 32 | verbose_name="플랫폼", 33 | ) 34 | created_at = models.DateTimeField( 35 | auto_now_add=True, 36 | verbose_name="생성 일시", 37 | ) 38 | updated_at = models.DateTimeField( 39 | auto_now=True, 40 | verbose_name="수정 일시", 41 | ) 42 | 43 | class Meta: 44 | db_table = "device" 45 | verbose_name = "디바이스" 46 | verbose_name_plural = "디바이스" 47 | 48 | 49 | class PushToken(models.Model): 50 | """푸시 토큰""" 51 | 52 | device = models.ForeignKey( 53 | Device, 54 | on_delete=models.CASCADE, 55 | related_name="push_tokens", 56 | verbose_name="디바이스", 57 | ) 58 | token = models.CharField( 59 | max_length=255, 60 | verbose_name="토큰", 61 | ) 62 | endpoint_arn = models.CharField( 63 | max_length=255, 64 | null=True, 65 | blank=True, 66 | verbose_name="엔드포인트 ARN", 67 | ) 68 | is_valid = models.BooleanField( 69 | default=True, 70 | verbose_name="유효 여부", 71 | ) 72 | created_at = models.DateTimeField( 73 | auto_now_add=True, 74 | verbose_name="생성 일시", 75 | ) 76 | updated_at = models.DateTimeField( 77 | auto_now=True, 78 | verbose_name="수정 일시", 79 | ) 80 | 81 | def save(self, *args, **kwargs): 82 | # 신규 등록 또는 유효한 토큰 업데이트 시 기존 토큰 무효화 83 | if self.is_valid: 84 | self.device.push_tokens.filter(is_valid=True).update(is_valid=False) 85 | super().save(*args, **kwargs) 86 | 87 | class Meta: 88 | db_table = "push_token" 89 | verbose_name = "푸시 토큰" 90 | verbose_name_plural = "푸시 토큰" 91 | unique_together = ["device", "token"] 92 | -------------------------------------------------------------------------------- /src/conf/authentications.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import SimpleLazyObject 2 | from django.utils.translation import gettext_lazy as _ 3 | from rest_framework_simplejwt import exceptions 4 | from rest_framework_simplejwt.authentication import JWTAuthentication 5 | from rest_framework_simplejwt.settings import api_settings 6 | 7 | from drf_spectacular.extensions import OpenApiAuthenticationExtension 8 | from drf_spectacular.plumbing import build_bearer_security_scheme_object 9 | 10 | 11 | class SimpleLazyUser(SimpleLazyObject): 12 | @property 13 | def is_authenticated(self): 14 | """인증 여부: 토큰 인증 시 인증된 사용자로 판단""" 15 | return True 16 | 17 | def __bool__(self): 18 | """사용자 존재: Permission 에서 사용자 인증 여부 확인""" 19 | return True 20 | 21 | 22 | class JWTLazyUserAuthentication(JWTAuthentication): 23 | """ 24 | JWT 사용자 지연 인증 25 | - 토큰 검증을 통해 인증을 진행 26 | - 데이터베이스에 저장된 사용자 정보는 실제 데이터가 사용되는 곳에서 지연 로딩 27 | """ 28 | 29 | def get_active_user(self, user_id): 30 | """지연 로딩: 사용자 조회 및 활성화 여부 확인""" 31 | try: 32 | user = self.user_model.objects.get(**{api_settings.USER_ID_FIELD: user_id}) 33 | except self.user_model.DoesNotExist: 34 | raise exceptions.AuthenticationFailed( 35 | _("User not found"), code="user_not_found" 36 | ) 37 | if api_settings.CHECK_USER_IS_ACTIVE and not user.is_active: 38 | raise exceptions.AuthenticationFailed( 39 | _("User is inactive"), code="user_inactive" 40 | ) 41 | return user 42 | 43 | def get_user(self, validated_token): 44 | """토큰을 이용한 사용자 조회""" 45 | try: 46 | user_id = validated_token[api_settings.USER_ID_CLAIM] 47 | except KeyError: 48 | raise exceptions.InvalidToken( 49 | _("Token contained no recognizable user identification") 50 | ) 51 | return SimpleLazyUser(lambda: self.get_active_user(user_id)) 52 | 53 | 54 | class JWTLazyUserAuthenticationScheme(OpenApiAuthenticationExtension): 55 | """ 56 | drf-spectacular 확장 클래스 57 | - 인증 확장 클래스 정의 58 | - 인증 확장 클래스 정의 59 | """ 60 | 61 | target_class = JWTLazyUserAuthentication 62 | name = "Bearer" 63 | 64 | def get_security_definition(self, auto_schema): 65 | """인증 확장 클래스 정의""" 66 | return build_bearer_security_scheme_object( 67 | header_name="Authorization", 68 | token_prefix="Bearer", 69 | bearer_format="JWT", 70 | ) 71 | -------------------------------------------------------------------------------- /src/apps/game/v1/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from drf_spectacular.utils import extend_schema 3 | from rest_framework import views, viewsets, mixins, exceptions, response 4 | from rest_framework.permissions import IsAuthenticated 5 | 6 | from apps.game.models import AttendanceCheck 7 | from apps.game.v1.serializers import AttendanceCheckSerializer 8 | 9 | 10 | class AttendanceCheckViewSet( 11 | viewsets.GenericViewSet, 12 | mixins.RetrieveModelMixin, 13 | mixins.UpdateModelMixin, 14 | ): 15 | """ 16 | 출석 체크: 17 | 하루에 1번 참여 가능한 출석 체크 18 | 총 연속 일수 7일이 주어지며 연속 일수가 높아질수록 지급 포인트도 높아짐 19 | 연속 일수나 지급 포인트는 settings 에서 변경 가능 20 | """ 21 | 22 | queryset = AttendanceCheck.objects.all() 23 | serializer_class = AttendanceCheckSerializer 24 | permission_classes = [IsAuthenticated] 25 | 26 | def get_object(self): 27 | # 가장 최근 출석 체크 기록을 조회 28 | return self.get_queryset().filter(user=self.request.user).last() 29 | 30 | @extend_schema( 31 | responses={ 32 | 200: AttendanceCheckSerializer, 33 | }, 34 | tags=["game"], 35 | summary="마지막 출석 체크 조회", 36 | description=""" 37 | 마지막 출석 체크 이력을 조회해서 반환 38 | """, 39 | ) 40 | def retrieve(self, request, *args, **kwargs): 41 | # [Why] 42 | # Q. 왜 마지막 출석 체크 이력만 반환하는가? 43 | # A. 마지막 출석 체크일, 연속 일수만 알고 있어도 모든 정보를 얻을 수 있음 44 | # 연속 미션이 유효한 상태인지, 45 | return super().retrieve(request, *args, **kwargs) 46 | 47 | @extend_schema(exclude=True) 48 | def update(self, request, *args, **kwargs): 49 | raise exceptions.MethodNotAllowed("PUT method is not allowed.") 50 | 51 | @extend_schema( 52 | request=None, 53 | responses={ 54 | 200: AttendanceCheckSerializer, 55 | }, 56 | tags=["game"], 57 | summary="출석 체크 참여", 58 | description=""" 59 | 오늘 출석 체크를 참여 60 | """, 61 | ) 62 | def partial_update(self, request, *args, **kwargs): 63 | kwargs["partial"] = True 64 | return super().update(request, *args, **kwargs) 65 | 66 | 67 | class AttendanceCheckRoleAPIView(views.APIView): 68 | """출석 체크 룰""" 69 | 70 | permission_classes = [IsAuthenticated] 71 | 72 | @extend_schema( 73 | responses={ 74 | 200: list[int], 75 | }, 76 | tags=["game"], 77 | summary="출석 체크 룰 조회", 78 | description=""" 79 | 출석 체크 룰을 조회 80 | """, 81 | ) 82 | def get(self, request, *args, **kwargs): 83 | return response.Response(settings.ATTENDANCE_CHECK_REWARD_POINTS) 84 | -------------------------------------------------------------------------------- /src/apps/user/admin.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib import admin 3 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 4 | from django.contrib.auth.forms import ( 5 | UserChangeForm, 6 | ) 7 | from django.contrib.auth.tokens import default_token_generator 8 | from django.core.mail import send_mail 9 | from django.template.loader import render_to_string 10 | from django.urls import reverse 11 | from django.utils.encoding import force_bytes 12 | from django.utils.http import urlsafe_base64_encode 13 | from django.utils.translation import gettext_lazy as _ 14 | 15 | from apps.user.forms import UserCreationForm 16 | from apps.user.models import User 17 | 18 | 19 | @admin.register(User) 20 | class UserAdmin(BaseUserAdmin): 21 | form = UserChangeForm 22 | add_form = UserCreationForm 23 | 24 | list_display = ["uuid", "email", "is_staff", "is_verified", "is_active"] 25 | list_filter = ["is_staff", "is_verified", "is_active"] 26 | search_fields = ["email", "uuid"] 27 | ordering = ["-date_joined"] 28 | 29 | fieldsets = ( 30 | (_("User Info"), {"fields": ["uuid", "email"]}), 31 | ( 32 | _("Permissions"), 33 | { 34 | "fields": [ 35 | "is_active", 36 | "is_verified", 37 | "is_staff", 38 | "is_superuser", 39 | "groups", 40 | "user_permissions", 41 | ] 42 | }, 43 | ), 44 | (_("Important dates"), {"fields": ["last_login", "date_joined"]}), 45 | ) 46 | 47 | add_fieldsets = [(None, {"fields": ["email"]})] 48 | readonly_fields = ["uuid", "last_login", "date_joined"] 49 | 50 | def save_model(self, request, obj, form, change): 51 | super().save_model(request, obj, form, change) 52 | if not change: 53 | # 신규 사용자 생성 시 OTP 설정 페이지를 전송 54 | self.send_setup_email(request, obj) 55 | 56 | def send_setup_email(self, request, user): 57 | token = default_token_generator.make_token(user) 58 | uid = urlsafe_base64_encode(force_bytes(user.pk)) 59 | setup_url = request.build_absolute_uri( 60 | reverse("setup_otp", kwargs={"uidb64": uid, "token": token}) 61 | ) 62 | message = render_to_string( 63 | "emails/setup_account.html", 64 | { 65 | "user": user, 66 | "setup_url": setup_url, 67 | }, 68 | ) 69 | 70 | send_mail( 71 | "Django Boilerplate 계정 설정을 완료해주세요.", 72 | message, 73 | settings.DEFAULT_FROM_EMAIL, 74 | [user.email], 75 | html_message=message, 76 | ) 77 | -------------------------------------------------------------------------------- /src/templates/otp/invalid_link.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% block extrahead %} 4 | 77 | {% endblock %} 78 | 79 | {% block content %} 80 |
81 |
82 | ⚠ 83 |
84 | 85 |

{% translate 'Invalid or Expired Link' %}

86 | 87 |
88 |

{% translate 'The link you followed is either invalid or has expired. Please contact the administrator.' %}

89 |
90 | 91 | 94 |
95 | {% endblock %} -------------------------------------------------------------------------------- /src/templates/otp/otp_already_set.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% block extrahead %} 4 | 81 | {% endblock %} 82 | 83 | {% block content %} 84 |
85 |
86 | ✓ 87 |
88 | 89 |

{% translate 'OTP is Already Set Up' %}

90 | 91 |
92 |

{% translate 'Your account already has an OTP device configured.' %}

93 |

{% translate 'If you need to change it, please contact the administrator.' %}

94 |
95 | 96 | 99 |
100 | {% endblock %} -------------------------------------------------------------------------------- /src/apps/agreement/v1/views.py: -------------------------------------------------------------------------------- 1 | from drf_spectacular.utils import extend_schema 2 | from rest_framework import viewsets, mixins, exceptions 3 | from rest_framework.permissions import IsAuthenticated, AllowAny 4 | 5 | from apps.agreement.models import Agreement, UserAgreement 6 | from apps.agreement.v1.paginations import AgreementLimitOffsetPagination 7 | from apps.agreement.v1.serializers import ( 8 | AgreementSerializer, 9 | UserAgreementSerializer, 10 | UserAgreementCreateSerializer, 11 | ) 12 | 13 | 14 | class AgreementViewSet( 15 | viewsets.GenericViewSet, 16 | mixins.ListModelMixin, 17 | ): 18 | """사용자 약관""" 19 | 20 | queryset = Agreement.objects.filter(is_active=True) 21 | serializer_class = AgreementSerializer 22 | pagination_class = AgreementLimitOffsetPagination 23 | permission_classes = [AllowAny] 24 | 25 | @extend_schema( 26 | responses={ 27 | 200: AgreementSerializer, 28 | }, 29 | tags=["account"], 30 | summary="사용자 약관 목록", 31 | description=""" 32 | 사용자 약관 목록을 조회합니다. 33 | """, 34 | ) 35 | def list(self, request, *args, **kwargs): 36 | return super().list(request, *args, **kwargs) 37 | 38 | 39 | class UserAgreementViewSet( 40 | viewsets.GenericViewSet, 41 | mixins.ListModelMixin, 42 | mixins.CreateModelMixin, 43 | mixins.UpdateModelMixin, 44 | ): 45 | """사용자 약관 동의""" 46 | 47 | queryset = UserAgreement.objects.filter(agreement__is_active=True) 48 | serializer_class = UserAgreementSerializer 49 | permission_classes = [IsAuthenticated] 50 | 51 | def get_queryset(self): 52 | """쿼리셋 조회""" 53 | return self.queryset.filter(user=self.request.user) 54 | 55 | def get_serializer_class(self): 56 | """시리얼라이저 클래스 조회""" 57 | if self.action == "create": 58 | return UserAgreementCreateSerializer 59 | return super().get_serializer_class() 60 | 61 | @extend_schema( 62 | responses={ 63 | 200: UserAgreementSerializer, 64 | }, 65 | tags=["user"], 66 | summary="사용자 약관 목록", 67 | description=""" 68 | 사용자 약관 목록을 조회합니다. 69 | """, 70 | ) 71 | def list(self, request, *args, **kwargs): 72 | return super().list(request, *args, **kwargs) 73 | 74 | @extend_schema( 75 | request=UserAgreementCreateSerializer, 76 | responses={ 77 | 201: UserAgreementCreateSerializer, 78 | }, 79 | tags=["user"], 80 | summary="사용자 약관 동의", 81 | description=""" 82 | 사용자 약관에 동의합니다. 83 | """, 84 | ) 85 | def create(self, request, *args, **kwargs): 86 | return super().create(request, *args, **kwargs) 87 | 88 | @extend_schema(exclude=True) 89 | def update(self, request, *args, **kwargs): 90 | raise exceptions.MethodNotAllowed("PUT method not allowed") 91 | 92 | @extend_schema( 93 | request=UserAgreementSerializer, 94 | responses={ 95 | 200: UserAgreementSerializer, 96 | }, 97 | tags=["user"], 98 | summary="사용자 약관 동의 업데이트", 99 | description=""" 100 | 사용자 약관 동의 정보를 업데이트합니다. 101 | """, 102 | ) 103 | def partial_update(self, request, *args, **kwargs): 104 | kwargs["partial"] = True 105 | return super().update(request, *args, **kwargs) 106 | -------------------------------------------------------------------------------- /src/apps/device/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.html import format_html 3 | 4 | from apps.device.models import Device, PushToken 5 | 6 | 7 | class PushTokenInline(admin.TabularInline): 8 | """푸시 토큰 인라인""" 9 | 10 | model = PushToken 11 | extra = 0 12 | readonly_fields = ["created_at", "updated_at"] 13 | fields = ["token", "endpoint_arn", "is_valid", "created_at", "updated_at"] 14 | show_change_link = True 15 | 16 | 17 | @admin.register(Device) 18 | class DeviceAdmin(admin.ModelAdmin): 19 | """디바이스 어드민""" 20 | 21 | list_display = [ 22 | "id", 23 | "uuid", 24 | "user_username", 25 | "created_at", 26 | "updated_at", 27 | "push_token_count", 28 | ] 29 | list_filter = ["created_at", "updated_at"] 30 | search_fields = ["uuid", "user__email"] 31 | readonly_fields = ["created_at", "updated_at"] 32 | raw_id_fields = ["user"] 33 | inlines = [PushTokenInline] 34 | 35 | def user_username(self, obj): 36 | """사용자 이름""" 37 | return obj.user.profile.nickname 38 | 39 | user_username.short_description = "사용자" 40 | 41 | def push_token_count(self, obj): 42 | """유효한 푸시 토큰 수""" 43 | valid_count = obj.push_tokens.filter(is_valid=True).count() 44 | total_count = obj.push_tokens.count() 45 | 46 | if valid_count == 0: 47 | color = "red" 48 | else: 49 | color = "green" 50 | 51 | return format_html( 52 | '{} / {}', color, valid_count, total_count 53 | ) 54 | 55 | push_token_count.short_description = "유효한 토큰 / 전체 토큰" 56 | 57 | 58 | @admin.register(PushToken) 59 | class PushTokenAdmin(admin.ModelAdmin): 60 | """푸시 토큰 어드민""" 61 | 62 | list_display = [ 63 | "id", 64 | "token_truncated", 65 | "device_uuid", 66 | "user_username", 67 | "is_valid", 68 | "created_at", 69 | "updated_at", 70 | ] 71 | list_filter = ["is_valid", "created_at", "updated_at"] 72 | search_fields = [ 73 | "token", 74 | "device__uuid", 75 | "device__user__email", 76 | ] 77 | readonly_fields = ["created_at", "updated_at"] 78 | raw_id_fields = ["device"] 79 | actions = ["mark_as_valid", "mark_as_invalid"] 80 | 81 | def token_truncated(self, obj): 82 | """토큰 (축약)""" 83 | if len(obj.token) > 20: 84 | return f"{obj.token[:20]}..." 85 | return obj.token 86 | 87 | token_truncated.short_description = "토큰" 88 | 89 | def device_uuid(self, obj): 90 | """디바이스 UUID""" 91 | return obj.device.uuid 92 | 93 | device_uuid.short_description = "디바이스 UUID" 94 | 95 | def user_username(self, obj): 96 | """사용자 이름""" 97 | return obj.device.user.profile.nickname 98 | 99 | user_username.short_description = "사용자" 100 | 101 | def mark_as_valid(self, request, queryset): 102 | """유효로 표시""" 103 | updated = queryset.update(is_valid=True) 104 | self.message_user(request, f"{updated}개의 토큰이 유효로 표시되었습니다.") 105 | 106 | mark_as_valid.short_description = "선택된 토큰을 유효로 표시" 107 | 108 | def mark_as_invalid(self, request, queryset): 109 | """무효로 표시""" 110 | updated = queryset.update(is_valid=False) 111 | self.message_user(request, f"{updated}개의 토큰이 무효로 표시되었습니다.") 112 | 113 | mark_as_invalid.short_description = "선택된 토큰을 무효로 표시" 114 | -------------------------------------------------------------------------------- /src/apps/common/management/app_template/v1/tests.py-tpl: -------------------------------------------------------------------------------- 1 | from rest_framework import status 2 | from rest_framework.test import APITestCase 3 | 4 | from apps.{{ app_name }}.models import {{ camel_case_app_name }} 5 | 6 | 7 | class {{ camel_case_app_name }}APITest(APITestCase): 8 | def setUp(self): 9 | self.user1 = self.create_user("test_user1", "test_password1") 10 | self.user2 = self.create_user("test_user2", "test_password2") 11 | super().setUp() 12 | 13 | def tearDown(self): 14 | super().tearDown() 15 | 16 | def test_success_test_cast1(self): 17 | # anonymous user 18 | resp = self.client.post( 19 | "/api/v1/{{ app_name }}/", 20 | { 21 | "name": "test_name", 22 | }, 23 | format="json", 24 | ) 25 | self.assertEqual(resp.status_code, status.HTTP_200_OK) 26 | self.assertEqual(resp.json()["name"], "test_name") 27 | 28 | def test_success_test_cast2(self): 29 | # authenticated user 30 | self.client.force_login(self.user1) 31 | resp = self.client.post( 32 | "/api/v1/{{ app_name }}/", 33 | { 34 | "name": "test_name", 35 | }, 36 | format="json", 37 | ) 38 | self.assertEqual(resp.status_code, status.HTTP_200_OK) 39 | self.assertEqual(resp.json()["name"], "test_name") 40 | 41 | def test_success_test_cast3(self): 42 | # frozen time 43 | with freeze_time("2025-01-01"): 44 | self.client.force_login(self.user1) 45 | resp = self.client.post( 46 | "/api/v1/{{ app_name }}/", 47 | { 48 | "name": "test_name", 49 | }, 50 | format="json", 51 | ) 52 | self.assertEqual(resp.status_code, status.HTTP_200_OK) 53 | self.assertEqual(resp.json()["name"], "test_name") 54 | 55 | def test_fail_test_cast1(self): 56 | # anonymous user 57 | resp = self.client.post( 58 | "/api/v1/{{ app_name }}/", 59 | { 60 | "name": "test_name", 61 | }, 62 | format="json", 63 | ) 64 | self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) 65 | 66 | def test_fail_test_cast2(self): 67 | # authenticated user 68 | self.client.force_login(self.user2) 69 | resp = self.client.post( 70 | "/api/v1/{{ app_name }}/", 71 | { 72 | "name": "test_name", 73 | }, 74 | format="json", 75 | ) 76 | self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) 77 | 78 | def test_fail_test_cast3(self): 79 | # raise exception 80 | resp = self.client.post( 81 | "/api/v1/{{ app_name }}/", 82 | { 83 | "name": "test_name", 84 | }, 85 | format="json", 86 | ) 87 | self.assertEqual(resp.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) 88 | 89 | def test_fail_test_cast4(self): 90 | # exception handler 91 | with self.assertRaises(Exception): 92 | resp = self.client.post( 93 | "/api/v1/{{ app_name }}/", 94 | { 95 | "name": "test_name", 96 | }, 97 | format="json", 98 | ) 99 | self.assertEqual(resp.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) 100 | -------------------------------------------------------------------------------- /src/templates/admin/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'unfold/layouts/unauthenticated.html' %} 2 | {% load i18n static unfold %} 3 | 4 | {% block extrastyle %} 5 | {{ block.super }} 6 | {{ form.media }} 7 | 32 | {% endblock %} 33 | 34 | {% block title %} 35 | {{ title }} | {{ site_title }} 36 | {% endblock %} 37 | 38 | {% block content %} 39 | {% include "unfold/helpers/unauthenticated_title.html" with title=site_title|default:_('Django site admin') subtitle=_('Welcome back to') %} 40 | 41 | {% include "unfold/helpers/messages.html" %} 42 | 43 | {% if form.errors or form.non_field_errors %} 44 |
45 | {% include "unfold/helpers/messages/errornote.html" with errors=form.errors %} 46 | 47 | {% include "unfold/helpers/messages/error.html" with errors=form.non_field_errors %} 48 | 49 | {% if user.is_authenticated %} 50 | {% blocktranslate trimmed asvar message %} 51 | You are authenticated as {{ username }}, but are not authorized to 52 | access this page. Would you like to login to a different account? 53 | {% endblocktranslate %} 54 | 55 | {% include "unfold/helpers/messages/error.html" with error=message %} 56 | {% endif %} 57 |
58 | {% endif %} 59 | 60 | {% block login_before %}{% endblock %} 61 | 62 |
63 | {% csrf_token %} 64 | 65 | {% include "unfold/helpers/field.html" with field=form.username %} 66 | 67 | {% include "unfold/helpers/field.html" with field=form.password %} 68 | 69 | {# Add the OTP Token field here #} 70 | {% if form.otp_token %} 71 | {% include "unfold/helpers/field.html" with field=form.otp_token %} 72 | {% endif %} 73 | 74 |
75 | {% component "unfold/components/button.html" with submit=1 variant="primary" class="submit-row w-full" %} 76 | {% translate 'Log in' %} arrow_forward 77 | {% endcomponent %} 78 | 79 | {% url 'admin_password_reset' as password_reset_url %} 80 | {% url 'admin:admin_password_reset' as unfold_password_reset_url %} 81 | 82 | {% if password_reset_url or unfold_password_reset_url %} 83 | {% component "unfold/components/button.html" with href=password_reset_url|default:unfold_password_reset_url variant="secondary" class="password-reset-link w-full" %} 84 | {% translate 'Forgotten your password or username?' %} 85 | {% endcomponent %} 86 | {% endif %} 87 |
88 |
89 | 90 | {% block login_after %}{% endblock %} 91 | {% endblock %} -------------------------------------------------------------------------------- /src/apps/common/management/app_template/v1/views.py-tpl: -------------------------------------------------------------------------------- 1 | from django_filters.rest_framework import DjangoFilterBackend 2 | from drf_spectacular.utils import extend_schema, extend_schema_view 3 | from rest_framework import mixins, viewsets, exceptions 4 | from rest_framework.filters import OrderingFilter 5 | from rest_framework.pagination import CursorPagination 6 | 7 | from apps.{{ app_name }}.models import {{ camel_case_app_name }} 8 | from apps.{{ app_name }}.v1.filters import {{ camel_case_app_name }}Filter 9 | from apps.{{ app_name }}.v1.permissions import {{ camel_case_app_name }}Permission 10 | from apps.{{ app_name }}.v1.serializers import {{ camel_case_app_name }}Serializer 11 | 12 | 13 | @extend_schema_view( 14 | list=extend_schema(summary="{{ camel_case_app_name }} 목록 조회"), 15 | create=extend_schema(summary="{{ camel_case_app_name }} 등록"), 16 | retrieve=extend_schema(summary="{{ camel_case_app_name }} 상세 조회"), 17 | update=extend_schema(summary="{{ camel_case_app_name }} 수정"), 18 | partial_update=extend_schema(exclude=True), 19 | destroy=extend_schema(summary="{{ camel_case_app_name }} 삭제"), 20 | ) 21 | class {{ camel_case_app_name }}ViewSet( 22 | mixins.ListModelMixin, 23 | mixins.CreateModelMixin, 24 | mixins.RetrieveModelMixin, 25 | mixins.UpdateModelMixin, 26 | mixins.DestroyModelMixin, 27 | viewsets.GenericViewSet, 28 | ): 29 | queryset = {{ camel_case_app_name }}.objects.all() 30 | serializer_class = {{ camel_case_app_name }}Serializer 31 | permission_classes = [{{ camel_case_app_name }}Permission] 32 | pagination_class = CursorPagination 33 | filterset_class = {{ camel_case_app_name }}Filter 34 | filter_backends = [DjangoFilterBackend, OrderingFilter] 35 | ordering_fields = ["created_at"] 36 | ordering = ["-created_at"] 37 | filter_fields = ["id"] 38 | serializer_classes_map = { 39 | "list": {{ camel_case_app_name }}ListSerializer, 40 | "retrieve": {{ camel_case_app_name }}RetrieveSerializer, 41 | "create": {{ camel_case_app_name }}CreateSerializer, 42 | "update": {{ camel_case_app_name }}UpdateSerializer, 43 | "partial_update": {{ camel_case_app_name }}UpdateSerializer, 44 | } 45 | 46 | def get_queryset(self): 47 | queryset = super().get_queryset() 48 | return queryset 49 | 50 | def get_object(self): 51 | return super().get_object() 52 | 53 | def get_permissions(self): 54 | return super().get_permissions() 55 | 56 | def get_serializer_class(self): 57 | if self.action in self.serializer_classes_map: 58 | return self.serializer_classes_map[self.action] 59 | return super().get_serializer_class() 60 | 61 | def perform_create(self, serializer): 62 | return super().perform_create(serializer) 63 | 64 | def perform_update(self, serializer): 65 | return super().perform_update(serializer) 66 | 67 | def perform_destroy(self, instance): 68 | return super().perform_destroy(instance) 69 | 70 | def list(self, request, *args, **kwargs): 71 | return super().list(request, *args, **kwargs) 72 | 73 | def retrieve(self, request, *args, **kwargs): 74 | return super().retrieve(request, *args, **kwargs) 75 | 76 | def create(self, request, *args, **kwargs): 77 | return super().create(request, *args, **kwargs) 78 | 79 | def update(self, request, *args, **kwargs): 80 | return super().update(request, *args, **kwargs) 81 | 82 | def partial_update(self, request, *args, **kwargs): 83 | raise exceptions.MethodNotAllowed("PATCH") 84 | 85 | def destroy(self, request, *args, **kwargs): 86 | return super().destroy(request, *args, **kwargs) 87 | -------------------------------------------------------------------------------- /src/apps/game/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.7 on 2025-04-09 12:59 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="GamePoint", 19 | fields=[ 20 | ( 21 | "id", 22 | models.BigAutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ( 30 | "point", 31 | models.IntegerField( 32 | help_text="지급/차감 포인트", verbose_name="포인트" 33 | ), 34 | ), 35 | ( 36 | "reason", 37 | models.PositiveSmallIntegerField( 38 | choices=[(1, "출석 체크"), (2, "동전 뒤집기")], 39 | verbose_name="사유", 40 | ), 41 | ), 42 | ( 43 | "created_at", 44 | models.DateTimeField(auto_now_add=True, verbose_name="생성 일시"), 45 | ), 46 | ( 47 | "user", 48 | models.ForeignKey( 49 | on_delete=django.db.models.deletion.CASCADE, 50 | related_name="game_points", 51 | to=settings.AUTH_USER_MODEL, 52 | verbose_name="사용자", 53 | ), 54 | ), 55 | ], 56 | options={ 57 | "verbose_name": "게임 포인트", 58 | "verbose_name_plural": "게임 포인트", 59 | "db_table": "game_point", 60 | }, 61 | ), 62 | migrations.CreateModel( 63 | name="AttendanceCheck", 64 | fields=[ 65 | ( 66 | "id", 67 | models.BigAutoField( 68 | auto_created=True, 69 | primary_key=True, 70 | serialize=False, 71 | verbose_name="ID", 72 | ), 73 | ), 74 | ( 75 | "check_in_date", 76 | models.DateField(db_index=True, verbose_name="참여 일자"), 77 | ), 78 | ( 79 | "consecutive_days", 80 | models.PositiveIntegerField(default=0, verbose_name="연속 일수"), 81 | ), 82 | ( 83 | "created_at", 84 | models.DateTimeField(auto_now_add=True, verbose_name="생성 일시"), 85 | ), 86 | ( 87 | "user", 88 | models.ForeignKey( 89 | on_delete=django.db.models.deletion.CASCADE, 90 | related_name="attendance_checks", 91 | to=settings.AUTH_USER_MODEL, 92 | verbose_name="사용자", 93 | ), 94 | ), 95 | ], 96 | options={ 97 | "verbose_name": "출석 체크 이력", 98 | "verbose_name_plural": "출석 체크 이력", 99 | "db_table": "attendance_check", 100 | "unique_together": {("user", "check_in_date")}, 101 | }, 102 | ), 103 | ] 104 | -------------------------------------------------------------------------------- /src/apps/user/v1/serializers.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from better_profanity import profanity 4 | from rest_framework import serializers 5 | 6 | from apps.user.models import UserProfile, UserPreference 7 | from base.enums.errors import ( 8 | E008_NICKNAME_CONTAINS_FORBIDDEN_WORD, 9 | E008_INVALID_NICKNAME_LENGTH, 10 | E008_INVALID_NICKNAME_SPACING, 11 | E008_INVALID_NICKNAME_CONSECUTIVE_SPACES, 12 | E008_INVALID_NICKNAME_FORMAT, 13 | E008_NICKNAME_CONTAINS_PROFANITY, 14 | ) 15 | 16 | 17 | class UserProfileSerializer(serializers.ModelSerializer): 18 | """ 19 | 사용자 프로필 시리얼라이저: 20 | 닉네임 유효성 검사 21 | 프로필 등록 22 | """ 23 | 24 | NICKNAME_ALLOWED_CHARS_REGEX = re.compile( 25 | r"^[a-zA-Z0-9가-힣\-_.+=^!\s" # 기본 문자 + 허용 특수문자 + 공백 26 | r"\U0001F300-\U0001F5FF" # Miscellaneous Symbols and Pictographs 27 | r"\U0001F600-\U0001F64F" # Emoticons 28 | r"\U0001F680-\U0001F6FF" # Transport and Map Symbols 29 | r"\u2600-\u26FF" # Miscellaneous Symbols 30 | r"\u2700-\u27BF" # Dingbats 31 | r"\uFE00-\uFE0F" # Variation Selectors (for emoji styling) 32 | r"\U0001FA70-\U0001FAFF" # Symbols and Pictographs Extended-A 33 | r"]+$" 34 | ) 35 | NICKNAME_MIN_LEN = 2 36 | NICKNAME_MAX_LEN = 30 37 | NICKNAME_FORBIDDEN_WORDS = { 38 | "admin", 39 | "administrator", 40 | "관리자", 41 | "운영자", 42 | "어드민", 43 | } 44 | 45 | def validate_nickname(self, value): 46 | """ 47 | 닉네임 유효성 검사 48 | 1. 길이 검사 (2~30자) 49 | 2. 시작/끝 공백 검사 50 | 3. 연속 공백 검사 51 | 4. 허용된 문자/기호/이모티콘/공백 검사 (정규식) 52 | 5. 금지된 단어 검사 (admin, 관리자 등) 53 | 6. 비속어 검사 54 | """ 55 | # 1. 길이 검사 56 | if not (self.NICKNAME_MIN_LEN <= len(value) <= self.NICKNAME_MAX_LEN): 57 | raise serializers.ValidationError(E008_INVALID_NICKNAME_LENGTH) 58 | 59 | # 2. 시작/끝 공백 검사 60 | if value.startswith(" ") or value.endswith(" "): 61 | raise serializers.ValidationError(E008_INVALID_NICKNAME_SPACING) 62 | 63 | # 3. 연속 공백 검사 64 | if " " in value: 65 | raise serializers.ValidationError(E008_INVALID_NICKNAME_CONSECUTIVE_SPACES) 66 | 67 | # 4. 허용된 문자 조합 검사 (정규식) 68 | # - 정규식은 전체 문자열이 허용된 문자들로만 구성되었는지 확인 69 | if not self.NICKNAME_ALLOWED_CHARS_REGEX.match(value): 70 | raise serializers.ValidationError(E008_INVALID_NICKNAME_FORMAT) 71 | 72 | # 5. 금지된 단어 검사 (대소문자 무시) 73 | if value.lower() in self.NICKNAME_FORBIDDEN_WORDS: 74 | raise serializers.ValidationError(E008_NICKNAME_CONTAINS_FORBIDDEN_WORD) 75 | 76 | # 6. 비속어 검사 77 | # - better_profanity 는 영어 중심일 수 있으므로, 필요시 한국어 비속어 라이브러리 추가 고려 78 | if profanity.contains_profanity(value): 79 | raise serializers.ValidationError(E008_NICKNAME_CONTAINS_PROFANITY) 80 | 81 | return value # 모든 검사를 통과하면 원래 값 반환 82 | 83 | class Meta: 84 | model = UserProfile 85 | fields = [ 86 | "id", 87 | "nickname", 88 | "image", 89 | ] 90 | 91 | 92 | class UserPreferenceSerializer(serializers.ModelSerializer): 93 | """ 94 | 사용자 선호도 시리얼라이저: 95 | 사용자 선호도 설정 96 | 추후 푸시 발송 시 사용 97 | """ 98 | 99 | def save(self, **kwargs): 100 | instance = super().save(**kwargs) 101 | # TODO: AWS SNS 의 구독 정보 업데이트 102 | return instance 103 | 104 | class Meta: 105 | model = UserPreference 106 | fields = [ 107 | "id", 108 | "user", 109 | "is_night_notification", 110 | "is_push_notification", 111 | "is_email_notification", 112 | "created_at", 113 | ] 114 | -------------------------------------------------------------------------------- /src/templates/otp/error.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% block extrahead %} 4 | 101 | {% endblock %} 102 | 103 | {% block content %} 104 |
105 |
106 | ⚠ 107 |
108 | 109 |

{% translate 'Error' %}

110 | 111 |
112 |

113 | {% if message %} 114 | {{ message }} 115 | {% else %} 116 | {% translate 'An unexpected error has occurred. Please try again later.' %} 117 | {% endif %} 118 |

119 |
120 | 121 |
122 |

{% translate 'If the problem persists, please contact:' %}

123 | lee@lou2.kr 124 |
125 | 126 | 127 | {% translate 'Go Back' %} 128 | 129 |
130 | {% endblock %} -------------------------------------------------------------------------------- /src/apps/short_url/admin.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | from django.contrib import admin 4 | 5 | from .models import ShortUrl, ShortUrlVisit 6 | 7 | 8 | @admin.register(ShortUrl) 9 | class ShortUrlAdmin(admin.ModelAdmin): 10 | """단축 URL 어드민""" 11 | 12 | list_display = [ 13 | "id", 14 | "random_key", 15 | "default_fallback_url", 16 | "ios_deep_link", 17 | "android_deep_link", 18 | "created_at", 19 | ] 20 | 21 | readonly_fields = [ 22 | "random_key", 23 | "hashed_value", 24 | "created_at", 25 | ] 26 | 27 | search_fields = [ 28 | "id", 29 | "random_key", 30 | "ios_deep_link", 31 | "android_deep_link", 32 | "default_fallback_url", 33 | ] 34 | 35 | list_filter = ["created_at"] 36 | 37 | fieldsets = ( 38 | ("기본 정보", {"fields": ("random_key", "hashed_value", "created_at")}), 39 | ( 40 | "URL 정보", 41 | { 42 | "fields": ( 43 | "default_fallback_url", 44 | "ios_deep_link", 45 | "ios_fallback_url", 46 | "android_deep_link", 47 | "android_fallback_url", 48 | ) 49 | }, 50 | ), 51 | ("OG 태그", {"fields": ("og_tag",)}), 52 | ) 53 | 54 | def has_add_permission(self, request): 55 | """생성 권한 비활성화""" 56 | return False 57 | 58 | def save_model(self, request, obj, form, change): 59 | """모델 저장 시 hashed_value 자동 업데이트""" 60 | # 기존 해시값 저장 61 | old_hash = obj.hashed_value 62 | 63 | # 새 해시값 생성 64 | concatenated = "".join( 65 | [ 66 | obj.ios_deep_link or "", 67 | obj.ios_fallback_url or "", 68 | obj.android_deep_link or "", 69 | obj.android_fallback_url or "", 70 | obj.default_fallback_url or "", 71 | ] 72 | ) 73 | 74 | # SHA-256 해시 생성 75 | hasher = hashlib.sha256() 76 | hasher.update(concatenated.encode("utf-8")) 77 | new_hash = hasher.hexdigest() 78 | 79 | # 해시값이 변경되었는지 확인 80 | if new_hash != old_hash: 81 | # 같은 해시값을 가진 다른 객체가 있는지 확인 82 | # 현재 객체는 제외하고 검색 83 | if ( 84 | ShortUrl.objects.exclude(id=obj.id) 85 | .filter(hashed_value=new_hash) 86 | .exists() 87 | ): 88 | # 중복된 해시값이 있으면 에러 발생 89 | self.message_user( 90 | request, 91 | f"저장할 수 없습니다: 같은 URL 조합을 가진 다른 단축 URL이 이미 존재합니다.", 92 | level="error", 93 | ) 94 | # 저장하지 않고 리턴 95 | return 96 | 97 | # 중복이 없으면 새 해시값으로 업데이트 98 | obj.hashed_value = new_hash 99 | 100 | # 모델 저장 101 | super().save_model(request, obj, form, change) 102 | 103 | 104 | @admin.register(ShortUrlVisit) 105 | class ShortUrlVisitAdmin(admin.ModelAdmin): 106 | """단축 URL 방문 어드민""" 107 | 108 | # 기존 코드와 동일 109 | list_display = [ 110 | "id", 111 | "short_url", 112 | "ip_address", 113 | "referrer", 114 | "created_at", 115 | ] 116 | 117 | readonly_fields = [ 118 | "short_url", 119 | "ip_address", 120 | "user_agent", 121 | "referrer", 122 | "created_at", 123 | ] 124 | 125 | search_fields = [ 126 | "short_url__random_key", 127 | "ip_address", 128 | "referrer", 129 | ] 130 | 131 | list_filter = ["created_at"] 132 | 133 | def has_add_permission(self, request): 134 | """생성 권한 비활성화""" 135 | return False 136 | 137 | def has_change_permission(self, request, obj=None): 138 | """수정 권한 비활성화""" 139 | return False 140 | 141 | def has_delete_permission(self, request, obj=None): 142 | """삭제 권한 비활성화""" 143 | return False 144 | -------------------------------------------------------------------------------- /src/apps/agreement/v1/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from apps.agreement.models import Agreement, UserAgreement, UserAgreementHistory 4 | from base.enums.errors import ( 5 | E009_AGREEMENT_ID_REQUIRED, 6 | E009_AGREEMENT_NOT_FOUND, 7 | E009_AGREEMENT_REQUIRED, 8 | E009_AGREEMENT_REQUIRED_ALL, 9 | ) 10 | 11 | 12 | class AgreementSerializer(serializers.ModelSerializer): 13 | """ 14 | 약관 리스트 시리얼라이저: 15 | 현재 유효한 약관을 조회 16 | """ 17 | 18 | class Meta: 19 | model = Agreement 20 | fields = [ 21 | "id", 22 | "title", 23 | "content", 24 | "version", 25 | "agreement_type", 26 | "order", 27 | "is_required", 28 | ] 29 | 30 | 31 | class UserAgreementSerializer(serializers.ModelSerializer): 32 | """ 33 | 사용자 약관 동의 시리얼라이저: 34 | 내가 동의/미동의한 약관 리스트 조회 및 업데이트 35 | """ 36 | 37 | agreement = AgreementSerializer(read_only=True, help_text="약관 정보") 38 | 39 | def validate_is_agreed(self, attr): 40 | # 필수 약관의 경우 is_agreed가 True여야 함 41 | if self.instance.agreement.is_required and not attr: 42 | raise serializers.ValidationError(E009_AGREEMENT_REQUIRED) 43 | return attr 44 | 45 | def update(self, instance, validated_data): 46 | before_data = { 47 | "is_agreed": instance.is_agreed, 48 | "updated_at": instance.updated_at.timestamp(), 49 | } 50 | instance = super().update(instance, validated_data) 51 | # 이 전 기록 저장 52 | UserAgreementHistory.objects.create( 53 | user_agreement=instance, 54 | data=before_data, 55 | ) 56 | return instance 57 | 58 | class Meta: 59 | model = UserAgreement 60 | fields = ["id", "agreement", "is_agreed"] 61 | 62 | 63 | class UserAgreementCreateItemSerializer(serializers.Serializer): 64 | """ 65 | 사용자 약관 동의 내부 항목 시리얼라이저: 66 | 회원 가입 시 리스트에 있는 각 약관 항목 67 | """ 68 | 69 | id = serializers.IntegerField(help_text="약관 ID") 70 | is_agreed = serializers.BooleanField(help_text="약관 동의 여부") 71 | 72 | 73 | class UserAgreementCreateSerializer(serializers.Serializer): 74 | """ 75 | 사용자 약관 동의 시리얼라이저: 76 | 회원 가입 시 리스트에 있는 약관들을 동의 77 | """ 78 | 79 | agreements = serializers.ListField( 80 | child=UserAgreementCreateItemSerializer(), 81 | required=True, 82 | write_only=True, 83 | help_text="사용자 약관 동의 정보", 84 | ) 85 | 86 | def validate_agreements(self, attrs): 87 | for attr in attrs: 88 | # 1. id, is_agreed 필드가 있는지 확인 89 | if "id" not in attr or "is_agreed" not in attr: 90 | raise serializers.ValidationError(E009_AGREEMENT_ID_REQUIRED) 91 | # 2. 존재하는 약관인지 확인 92 | agreement = Agreement.objects.filter(id=attr["id"]).first() 93 | if not agreement: 94 | raise serializers.ValidationError(E009_AGREEMENT_NOT_FOUND) 95 | # 3. 필수 약관인데 동의하지 않았는지 확인 96 | if agreement.is_required and not attr["is_agreed"]: 97 | raise serializers.ValidationError(E009_AGREEMENT_REQUIRED) 98 | # 4. 활성화된 약관 중 포함되지 않은 약관이 있는지 확인 99 | if ( 100 | Agreement.objects.filter(is_active=True) 101 | .exclude(id__in=[item["id"] for item in attrs]) 102 | .exists() 103 | ): 104 | raise serializers.ValidationError(E009_AGREEMENT_REQUIRED_ALL) 105 | return attrs 106 | 107 | def create(self, validated_data): 108 | # 약관 동의 정보 저장 109 | UserAgreement.objects.bulk_create( 110 | [ 111 | UserAgreement( 112 | user=self.context["request"].user, 113 | agreement_id=item["id"], 114 | is_agreed=item["is_agreed"], 115 | ) 116 | for item in validated_data["agreements"] 117 | ] 118 | ) 119 | return validated_data 120 | -------------------------------------------------------------------------------- /src/apps/file/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import GenericForeignKey 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models 4 | from uuid_extensions import uuid7 5 | 6 | 7 | class FileStatus(models.IntegerChoices): 8 | """파일 상태""" 9 | 10 | READY = 1, "준비" 11 | GENERATED = 2, "생성 완료" 12 | SUCCESS_UPLOAD = 3, "업로드 성공" 13 | FAIL_UPLOAD = 4, "업로드 실패" 14 | DELETE = 5, "삭제" 15 | 16 | 17 | class FileContentType(models.IntegerChoices): 18 | """파일 타입""" 19 | 20 | OTHER = 1, "기타" 21 | QNA = 2, "QnA" 22 | 23 | 24 | class File(models.Model): 25 | """파일""" 26 | 27 | uuid = models.UUIDField( 28 | primary_key=True, 29 | default=uuid7, 30 | editable=False, 31 | verbose_name="저장에 사용되는 파일 ID", 32 | ) 33 | user = models.ForeignKey( 34 | "user.User", 35 | on_delete=models.CASCADE, 36 | related_name="files", 37 | ) 38 | file_name = models.CharField( 39 | max_length=100, 40 | verbose_name="실제 파일명", 41 | ) 42 | extension = models.CharField( 43 | max_length=10, 44 | verbose_name="파일 확장자", 45 | ) 46 | file_size = models.IntegerField( 47 | default=0, 48 | verbose_name="파일 크기", 49 | ) 50 | object_key = models.TextField( 51 | verbose_name="파일 URL", 52 | null=True, 53 | blank=True, 54 | ) 55 | # [Why] 56 | # Q. ContentType, ObjectId를 사용한 이유는? 57 | # A. 파일은 주로 특정 객체에 연결되어 사용되며, 이 객체는 다양한 모델일 수 있음 58 | # 따라서, GenericForeignKey를 사용하여 다양한 모델과 연결할 수 있도록 함 59 | # Q. nullable 한 이유는? 60 | # A. 파일이 특정 객체에 연결되지 않을 수도 있음 61 | content_type = models.ForeignKey( 62 | ContentType, 63 | on_delete=models.SET_NULL, 64 | related_name="files", 65 | null=True, 66 | verbose_name="컨텐츠 타입", 67 | ) 68 | object_id = models.CharField( 69 | max_length=36, 70 | null=True, 71 | verbose_name="컨텐츠 ID", 72 | ) 73 | content_object = GenericForeignKey("content_type", "object_id") 74 | status = models.PositiveSmallIntegerField( 75 | choices=FileStatus, 76 | verbose_name="파일 상태", 77 | default=FileStatus.READY, 78 | ) 79 | expire_at = models.DateTimeField( 80 | null=True, 81 | blank=True, 82 | verbose_name="만료일시", 83 | db_index=True, 84 | ) 85 | created_at = models.DateTimeField( 86 | auto_now_add=True, 87 | verbose_name="생성 일시", 88 | ) 89 | updated_at = models.DateTimeField( 90 | auto_now=True, 91 | verbose_name="수정 일시", 92 | ) 93 | 94 | class Meta: 95 | db_table = "file" 96 | verbose_name = "파일" 97 | verbose_name_plural = verbose_name 98 | # [Why] 99 | # Q. ContentType, ObjectId를 복합 키로 설정한 이유는? 100 | # A. 항상 두 값이 함께 사용되며, 이 조합이 유일한 값을 보장하기 때문 101 | indexes = [ 102 | models.Index(fields=["content_type", "object_id"]), 103 | ] 104 | 105 | 106 | # [Why] 107 | # Q. FileDownloadHistory 모델을 만든 이유는? 108 | # A. 파일 다운로드 이력을 저장하기 위해 109 | # Q. 어떤 정보를 저장하는가? 110 | # A. 다운로드를 요청한 사용자, 다운로드 사유, 다운로드한 파일, 생성일시 111 | class FileDownloadHistory(models.Model): 112 | """파일 다운로드 이력""" 113 | 114 | user = models.ForeignKey( 115 | "user.User", 116 | on_delete=models.CASCADE, 117 | related_name="file_download_history", 118 | ) 119 | reason = models.CharField( 120 | max_length=100, 121 | verbose_name="다운로드 사유", 122 | null=True, 123 | blank=True, 124 | ) 125 | file = models.ForeignKey( 126 | File, 127 | on_delete=models.CASCADE, 128 | related_name="download_history", 129 | ) 130 | created_at = models.DateTimeField( 131 | auto_now_add=True, 132 | verbose_name="생성일시", 133 | ) 134 | 135 | class Meta: 136 | db_table = "file_download_history" 137 | verbose_name = "파일 다운로드 이력" 138 | verbose_name_plural = verbose_name 139 | -------------------------------------------------------------------------------- /docs/ko/deploy/docker-setup.md: -------------------------------------------------------------------------------- 1 | ## 🐳 Docker를 이용한 개발 및 배포 2 | 3 | ### 로컬 개발 환경 설정 4 | 5 | Docker와 Docker Compose를 사용하면 복잡한 개발 환경을 쉽게 구성할 수 있습니다. 이 프로젝트는 웹 서버, Celery 워커, PostgreSQL, Redis를 포함한 전체 스택을 Docker Compose로 구성하고 있습니다. 6 | 7 | #### 사전 요구사항 8 | 9 | - [Docker](https://docs.docker.com/get-docker/) 설치 10 | - [Docker Compose](https://docs.docker.com/compose/install/) 설치 11 | 12 | #### 개발 환경 실행 13 | 14 | ```bash 15 | # 프로젝트 루트 디렉토리에서 16 | docker-compose up -d 17 | 18 | # 로그 확인 19 | docker-compose logs -f 20 | 21 | # 특정 서비스 로그만 확인 22 | docker-compose logs -f web 23 | ``` 24 | 25 | #### 개발 환경 접속 26 | 27 | - Django 웹 서버: http://localhost:8000 28 | - 데이터베이스: PostgreSQL (localhost:5432) 29 | - Redis: localhost:6379 30 | 31 | #### 관리 명령어 실행 32 | 33 | ```bash 34 | # Django 관리 명령어 실행 35 | docker-compose exec web python manage.py [command] 36 | 37 | # 예: 마이그레이션 생성 38 | docker-compose exec web python manage.py makemigrations 39 | 40 | # 예: 슈퍼유저 생성 41 | docker-compose exec web python manage.py createsuperuser 42 | ``` 43 | 44 | ### 온프레미스/서버 배포 45 | 46 | #### 프로덕션 환경 설정 47 | 48 | 1. 프로덕션 환경 변수 설정 49 | 50 | ```bash 51 | # .env 파일 생성 52 | cp src/.env.example src/.env 53 | 54 | # .env 파일을 프로덕션 환경에 맞게 수정 55 | # 특히 다음 변수들을 꼭 변경하세요: 56 | # - SECRET_KEY: 보안을 위해 긴 랜덤 문자열로 설정 57 | # - DEBUG: False로 설정 58 | # - ALLOWED_HOSTS: 실제 도메인 이름 설정 59 | # - DATABASE_URL: 프로덕션 데이터베이스 연결 정보 60 | ``` 61 | 62 | 2. docker-compose.yml 최적화 (선택사항) 63 | 64 | ```bash 65 | # 프로덕션용 docker-compose 파일 생성 66 | cp docker-compose.yml docker-compose.prod.yml 67 | 68 | # docker-compose.prod.yml 편집: 69 | # - 볼륨 설정 최적화 70 | # - 환경 변수 DJANGO_SETTINGS_MODULE을 conf.settings.production으로 변경 71 | # - 워커 수 및 스레드 수 조정 72 | ``` 73 | 74 | 3. 배포 실행 75 | 76 | ```bash 77 | # 프로덕션 배포 78 | docker-compose -f docker-compose.prod.yml up -d 79 | 80 | # 마이그레이션 실행 81 | docker-compose -f docker-compose.prod.yml exec web python manage.py migrate 82 | 83 | # 정적 파일 수집 84 | docker-compose -f docker-compose.prod.yml exec web python manage.py collectstatic --noinput 85 | ``` 86 | 87 | #### NGINX 프록시 설정 (권장) 88 | 89 | 프로덕션 환경에서는 NGINX를 앞단에 두어 정적 파일 서빙과 SSL 종료를 처리하는 것이 좋습니다. 90 | 91 | ```nginx 92 | server { 93 | listen 80; 94 | server_name your-domain.com; 95 | 96 | location /static/ { 97 | alias /path/to/your/static/files/; 98 | } 99 | 100 | location /media/ { 101 | alias /path/to/your/media/files/; 102 | } 103 | 104 | location / { 105 | proxy_pass http://localhost:8000; 106 | proxy_set_header Host $host; 107 | proxy_set_header X-Real-IP $remote_addr; 108 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 109 | proxy_set_header X-Forwarded-Proto $scheme; 110 | } 111 | } 112 | ``` 113 | 114 | ### Docker 컨테이너 관리 115 | 116 | #### 기본 명령어 117 | 118 | ```bash 119 | # 전체 스택 시작 120 | docker-compose up -d 121 | 122 | # 전체 스택 중지 123 | docker-compose down 124 | 125 | # 특정 서비스만 재시작 126 | docker-compose restart web 127 | 128 | # 컨테이너 로그 확인 129 | docker-compose logs -f [service_name] 130 | 131 | # 컨테이너 내부 접속 132 | docker-compose exec [service_name] bash 133 | ``` 134 | 135 | #### 데이터베이스 백업 및 복원 136 | 137 | ```bash 138 | # PostgreSQL 데이터베이스 백업 139 | docker-compose exec db pg_dump -U postgres postgres > backup.sql 140 | 141 | # PostgreSQL 데이터베이스 복원 142 | cat backup.sql | docker-compose exec -T db psql -U postgres postgres 143 | ``` 144 | 145 | #### 리소스 모니터링 146 | 147 | ```bash 148 | # 실행 중인 컨테이너 확인 149 | docker-compose ps 150 | 151 | # 컨테이너 리소스 사용량 확인 152 | docker stats 153 | ``` 154 | 155 | ### 스케일링 및 성능 최적화 156 | 157 | #### 웹 애플리케이션 스케일링 158 | 159 | ```bash 160 | # 웹 서비스 인스턴스 수 조정 161 | docker-compose up -d --scale web=3 162 | ``` 163 | 164 | #### 워커 스케일링 165 | 166 | ```bash 167 | # Celery 워커 인스턴스 수 조정 168 | docker-compose up -d --scale worker=5 169 | ``` 170 | 171 | #### Gunicorn 설정 최적화 172 | 173 | 웹 서비스 성능을 최적화하려면 Dockerfile이나 docker-compose.yml에서 Gunicorn 설정을 조정하세요: 174 | 175 | ``` 176 | # 워커 수 = (2 * CPU 코어 수) + 1 177 | # 스레드 수 = 2-4 178 | CMD ["gunicorn", "--bind", "0.0.0.0:8000", "conf.wsgi:application", "--workers", "5", "--threads", "2"] 179 | ``` 180 | -------------------------------------------------------------------------------- /src/apps/cms/v1/views.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.utils import timezone 3 | from django.utils.decorators import method_decorator 4 | from django.views.decorators.cache import cache_page 5 | from drf_spectacular.utils import extend_schema 6 | from rest_framework import viewsets, mixins 7 | from rest_framework.permissions import IsAuthenticated 8 | 9 | from apps.cms.models import Notice, Event, Faq 10 | from apps.cms.v1.filters import NoticeFilterSet, EventFilterSet, FaqFilterSet 11 | from apps.cms.v1.serializers import NoticeSerializer, EventSerializer, FaqSerializer 12 | 13 | 14 | class NoticeViewSet( 15 | viewsets.GenericViewSet, 16 | mixins.RetrieveModelMixin, 17 | mixins.ListModelMixin, 18 | ): 19 | """공지사항 뷰셋""" 20 | 21 | queryset = Notice.objects.filter(is_published=True) 22 | serializer_class = NoticeSerializer 23 | permission_classes = [IsAuthenticated] 24 | filterset_class = NoticeFilterSet 25 | 26 | def get_queryset(self): 27 | now = timezone.now() 28 | return self.queryset.filter( 29 | Q(end_at__gte=now) | Q(end_at__isnull=True), 30 | start_at__lte=now, 31 | ) 32 | 33 | @extend_schema( 34 | responses={ 35 | 200: NoticeSerializer(many=True), 36 | }, 37 | tags=["cms"], 38 | summary="공지사항 리스트 조회", 39 | description=""" 40 | 공지사항 리스트를 조회합니다. 41 | """, 42 | ) 43 | @method_decorator(cache_page(60 * 5)) 44 | def list(self, request, *args, **kwargs): 45 | return super().list(request, *args, **kwargs) 46 | 47 | @extend_schema( 48 | responses={ 49 | 200: NoticeSerializer, 50 | }, 51 | tags=["cms"], 52 | summary="공지사항 상세 조회", 53 | description=""" 54 | 공지사항 상세를 조회합니다. 55 | """, 56 | ) 57 | @method_decorator(cache_page(60 * 5)) 58 | def retrieve(self, request, *args, **kwargs): 59 | return super().retrieve(request, *args, **kwargs) 60 | 61 | 62 | class EventViewSet( 63 | viewsets.GenericViewSet, 64 | mixins.RetrieveModelMixin, 65 | mixins.ListModelMixin, 66 | ): 67 | """이벤트 뷰셋""" 68 | 69 | queryset = Event.objects.filter(is_published=True) 70 | serializer_class = EventSerializer 71 | permission_classes = [IsAuthenticated] 72 | filterset_class = EventFilterSet 73 | 74 | def get_queryset(self): 75 | now = timezone.now() 76 | return self.queryset.filter( 77 | Q(end_at__gte=now) | Q(end_at__isnull=True), 78 | start_at__lte=now, 79 | ) 80 | 81 | @extend_schema( 82 | responses={ 83 | 200: EventSerializer(many=True), 84 | }, 85 | tags=["cms"], 86 | summary="이벤트 리스트 조회", 87 | description=""" 88 | 이벤트 리스트를 조회합니다. 89 | """, 90 | ) 91 | @method_decorator(cache_page(60 * 5)) 92 | def list(self, request, *args, **kwargs): 93 | return super().list(request, *args, **kwargs) 94 | 95 | @extend_schema( 96 | responses={ 97 | 200: EventSerializer, 98 | }, 99 | tags=["cms"], 100 | summary="이벤트 상세 조회", 101 | description=""" 102 | 이벤트 상세를 조회합니다. 103 | """, 104 | ) 105 | @method_decorator(cache_page(60 * 5)) 106 | def retrieve(self, request, *args, **kwargs): 107 | return super().retrieve(request, *args, **kwargs) 108 | 109 | 110 | class FaqViewSet( 111 | viewsets.GenericViewSet, 112 | mixins.ListModelMixin, 113 | ): 114 | """FAQ 뷰셋""" 115 | 116 | queryset = Faq.objects.filter(is_published=True) 117 | serializer_class = FaqSerializer 118 | permission_classes = [IsAuthenticated] 119 | filterset_class = FaqFilterSet 120 | 121 | @extend_schema( 122 | responses={ 123 | 200: FaqSerializer(many=True), 124 | }, 125 | tags=["cms"], 126 | summary="FAQ 리스트 조회", 127 | description=""" 128 | FAQ 리스트를 조회합니다. 129 | """, 130 | ) 131 | @method_decorator(cache_page(60 * 5)) 132 | def list(self, request, *args, **kwargs): 133 | return super().list(request, *args, **kwargs) 134 | -------------------------------------------------------------------------------- /src/apps/cms/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils import timezone 3 | from .models import Notice, Event, Faq 4 | 5 | 6 | @admin.action(description="선택된 항목들을 발행 상태로 변경") 7 | def make_published(modeladmin, request, queryset): 8 | queryset_to_update_published_at = queryset.filter(published_at__isnull=True) 9 | queryset_to_update_published_at.update(published_at=timezone.now()) 10 | queryset.update(is_published=True) 11 | 12 | 13 | @admin.action(description="선택된 항목들을 미발행 상태로 변경") 14 | def make_unpublished(modeladmin, request, queryset): 15 | queryset.update(is_published=False) 16 | 17 | 18 | @admin.register(Notice) 19 | class NoticeAdmin(admin.ModelAdmin): 20 | list_display = ( 21 | "title", 22 | "author", 23 | "is_published", 24 | "published_at", 25 | "start_at", 26 | "end_at", 27 | "created_at", 28 | ) 29 | list_filter = ( 30 | "is_published", 31 | "author", 32 | "start_at", 33 | "end_at", 34 | ) 35 | search_fields = ( 36 | "title", 37 | "content", 38 | "author__profile__nickname", 39 | ) 40 | date_hierarchy = "published_at" 41 | ordering = ("-published_at", "-created_at") 42 | 43 | fieldsets = ( 44 | ("기본 정보", {"fields": ("title", "author", "content")}), 45 | ( 46 | "발행 및 표시 설정", 47 | {"fields": ("is_published", "published_at", "start_at", "end_at")}, 48 | ), 49 | ( 50 | "메타 정보 (읽기 전용)", 51 | { 52 | "fields": ("uuid", "created_at", "updated_at"), 53 | "classes": ("collapse",), 54 | }, 55 | ), 56 | ) 57 | readonly_fields = ("uuid", "created_at", "updated_at") 58 | actions = [make_published, make_unpublished] 59 | 60 | 61 | @admin.register(Event) 62 | class EventAdmin(admin.ModelAdmin): 63 | list_display = ( 64 | "title", 65 | "author", 66 | "is_published", 67 | "published_at", 68 | "start_at", 69 | "end_at", 70 | "event_start_at", 71 | "event_end_at", 72 | "created_at", 73 | ) 74 | list_filter = ( 75 | "is_published", 76 | "author", 77 | "start_at", 78 | "event_start_at", 79 | ) 80 | search_fields = ( 81 | "title", 82 | "content", 83 | "author__profile__nickname", 84 | ) 85 | date_hierarchy = "event_start_at" 86 | ordering = ("-event_start_at", "-created_at") 87 | 88 | fieldsets = ( 89 | ("기본 정보", {"fields": ("title", "author", "content")}), 90 | ("이벤트 기간", {"fields": ("event_start_at", "event_end_at")}), 91 | ( 92 | "발행 및 표시 설정", 93 | {"fields": ("is_published", "published_at", "start_at", "end_at")}, 94 | ), 95 | ( 96 | "메타 정보 (읽기 전용)", 97 | {"fields": ("uuid", "created_at", "updated_at"), "classes": ("collapse",)}, 98 | ), 99 | ) 100 | readonly_fields = ("uuid", "created_at", "updated_at") 101 | actions = [make_published, make_unpublished] 102 | 103 | 104 | @admin.register(Faq) 105 | class FaqAdmin(admin.ModelAdmin): 106 | list_display = ( 107 | "title", 108 | "author", 109 | "is_published", 110 | "published_at", 111 | "created_at", 112 | ) 113 | list_filter = ( 114 | "is_published", 115 | "author", 116 | ) 117 | search_fields = ( 118 | "title", 119 | "content", 120 | "author__profile__nickname", 121 | ) 122 | date_hierarchy = "published_at" 123 | ordering = ("-published_at", "-created_at") 124 | 125 | fieldsets = ( 126 | ("기본 정보", {"fields": ("title", "author", "content")}), 127 | ( 128 | "발행 설정", 129 | {"fields": ("is_published", "published_at")}, 130 | ), 131 | ( 132 | "메타 정보 (읽기 전용)", 133 | {"fields": ("uuid", "created_at", "updated_at"), "classes": ("collapse",)}, 134 | ), 135 | ) 136 | readonly_fields = ("uuid", "created_at", "updated_at") 137 | actions = [make_published, make_unpublished] 138 | -------------------------------------------------------------------------------- /src/apps/file/v1/utils.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import logging 3 | import boto3 4 | from botocore.config import Config 5 | from botocore.exceptions import ClientError 6 | from django.conf import settings 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | S3_CLIENT = None 11 | try: 12 | S3_CLIENT = boto3.client( 13 | "s3", 14 | region_name=getattr(settings, "AWS_S3_REGION_NAME", "ap-northeast-2"), 15 | config=Config( 16 | signature_version=getattr(settings, "AWS_S3_SIGNATURE_VERSION", "s3v4"), 17 | ), 18 | ) 19 | logger.info("Successfully initialized S3 client.") 20 | except Exception as e: 21 | logger.error(f"Error initializing S3 client: {e}", exc_info=True) 22 | 23 | 24 | def generate_upload_presigned_url( 25 | object_key: str, 26 | file_size: int = 0, # 기본 파일 크기 27 | expires_in: int = 300, # 기본 만료 시간 (초) 28 | ) -> dict: 29 | """ 30 | S3 업로드용 Presigned POST URL 생성 31 | 32 | Args: 33 | object_key (str): S3에 저장될 객체 키 (파일 경로 및 이름) 34 | file_size (int): 예상 파일 크기 (바이트 단위). content-length-range 조건에 사용됨. 35 | expires_in (int): Presigned URL의 유효 시간 (초) 36 | 37 | Returns: 38 | dict: Presigned POST URL 및 필드 정보 39 | None: 클라이언트가 없거나 오류 발생 시 40 | """ 41 | if not S3_CLIENT: 42 | logger.error("S3 client is not available for generating upload presigned URL.") 43 | return {} 44 | # 파일 확장자로부터 Content-Type 조회 45 | content_type, _ = mimetypes.guess_type(object_key) 46 | if not content_type: 47 | content_type = "application/octet-stream" 48 | logger.warning( 49 | f"Could not guess Content-Type for {object_key}. Defaulting to {content_type}." 50 | ) 51 | max_upload_size = (file_size if file_size > 0 else 100 * 1024 * 1024) + ( 52 | 10 * 1024 * 1024 53 | ) 54 | fields = {"Content-Type": content_type} 55 | conditions = [ 56 | {"Content-Type": content_type}, 57 | ["content-length-range", 0, max_upload_size], 58 | ] 59 | try: 60 | response = S3_CLIENT.generate_presigned_post( 61 | Bucket=settings.AWS_STORAGE_BUCKET_NAME, 62 | Key=object_key, 63 | Fields=fields, 64 | Conditions=conditions, 65 | ExpiresIn=expires_in, 66 | ) 67 | return response 68 | except ClientError as e: 69 | logger.error( 70 | f"Error generating upload presigned URL for {object_key}: {e}", 71 | exc_info=True, 72 | ) 73 | return {} 74 | except Exception as e: 75 | # 기타 예외 발생 시 로깅 76 | logger.error( 77 | f"Unexpected error generating upload presigned URL for {object_key}: {e}", 78 | exc_info=True, 79 | ) 80 | return {} 81 | 82 | 83 | def generate_download_presigned_url( 84 | object_key: str, 85 | expires_in: int = 300, # 기본 만료 시간 (초) 86 | ) -> dict: 87 | """ 88 | S3 다운로드용 Presigned URL 생성 (공유 클라이언트 사용) 89 | 90 | Args: 91 | object_key (str): 다운로드할 S3 객체 키 92 | expires_in (int): Presigned URL의 유효 시간 (초) 93 | 94 | Returns: 95 | str: Presigned GET URL 96 | None: 클라이언트가 없거나 오류 발생 시 97 | """ 98 | if not S3_CLIENT: 99 | logger.error( 100 | "S3 client is not available for generating download presigned URL." 101 | ) 102 | return {} 103 | 104 | try: 105 | response = S3_CLIENT.generate_presigned_url( 106 | "get_object", 107 | Params={ 108 | "Bucket": settings.AWS_STORAGE_BUCKET_NAME, # 버킷 이름은 settings에서 가져옴 109 | "Key": object_key, 110 | }, 111 | ExpiresIn=expires_in, 112 | ) 113 | return response 114 | except ClientError as e: 115 | logger.error( 116 | f"Error generating download presigned URL for {object_key}: {e}", 117 | exc_info=True, 118 | ) 119 | return {} 120 | except Exception as e: 121 | logger.error( 122 | f"Unexpected error generating download presigned URL for {object_key}: {e}", 123 | exc_info=True, 124 | ) 125 | return {} 126 | -------------------------------------------------------------------------------- /src/apps/file/v1/views.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from drf_spectacular.utils import extend_schema 3 | from rest_framework import viewsets, mixins, exceptions 4 | from rest_framework.decorators import action 5 | from rest_framework.permissions import IsAuthenticated 6 | from rest_framework.throttling import ScopedRateThrottle 7 | 8 | from apps.file.models import File, FileStatus 9 | from apps.file.v1.filters import FileFilterSet 10 | from apps.file.v1.serializers import ( 11 | FileUploadSerializer, 12 | FileDownloadSerializer, 13 | FileUpdateSerializer, 14 | FileDownloadPresignedSerializer, 15 | ) 16 | 17 | 18 | class FileViewSet( 19 | viewsets.GenericViewSet, 20 | mixins.CreateModelMixin, 21 | mixins.UpdateModelMixin, 22 | mixins.ListModelMixin, 23 | ): 24 | """파일 업로드 뷰셋""" 25 | 26 | queryset = File.objects.exclude(status=FileStatus.DELETE) 27 | serializer_class = FileUploadSerializer 28 | permission_classes = [IsAuthenticated] 29 | filterset_class = FileFilterSet 30 | throttle_scope = "file" 31 | 32 | def get_throttles(self): 33 | """요청 속도 제한 설정""" 34 | if self.action in ["create", "partial_update", "presigned"]: 35 | self.throttle_scope = f"{self.throttle_scope}:{self.action}" 36 | return [ScopedRateThrottle()] 37 | return super().get_throttles() 38 | 39 | def get_queryset(self): 40 | """쿼리셋 조회""" 41 | now = timezone.now() 42 | return ( 43 | super() 44 | .get_queryset() 45 | .filter( 46 | user=self.request.user, 47 | expire_at__gt=now, 48 | ) 49 | ) 50 | 51 | def get_serializer_class(self): 52 | """시리얼라이저 클래스 설정""" 53 | if self.action == "partial_update": 54 | return FileUpdateSerializer 55 | elif self.action == "presigned": 56 | return FileDownloadPresignedSerializer 57 | elif self.action == "list": 58 | return FileDownloadSerializer 59 | return super().get_serializer_class() 60 | 61 | @extend_schema( 62 | request=FileUploadSerializer, 63 | responses={ 64 | 201: FileUploadSerializer, 65 | }, 66 | tags=["file"], 67 | summary="파일 생성, 프리사인드 생성", 68 | description=""" 69 | 파일의 기본 정보를 저장합니다 70 | 파일 업로드를 위한 프리사인드 URL을 생성합니다 71 | """, 72 | ) 73 | def create(self, request, *args, **kwargs): 74 | return super().create(request, *args, **kwargs) 75 | 76 | @extend_schema(exclude=True) 77 | def update(self, request, *args, **kwargs): 78 | raise exceptions.MethodNotAllowed("PUT method not allowed") 79 | 80 | @extend_schema( 81 | request=FileUpdateSerializer, 82 | responses={ 83 | 200: FileUpdateSerializer, 84 | }, 85 | tags=["file"], 86 | summary="파일 상태 변경", 87 | description=""" 88 | 프리사인드 URL을 이용하여 파일을 업로드한 후 89 | 파일의 상태를 변경합니다 90 | """, 91 | ) 92 | def partial_update(self, request, *args, **kwargs): 93 | kwargs["partial"] = True 94 | return super().update(request, *args, **kwargs) 95 | 96 | @extend_schema( 97 | request=FileDownloadSerializer, 98 | responses={ 99 | 200: FileDownloadSerializer, 100 | }, 101 | tags=["file"], 102 | summary="파일 다운로드 리스트 조회", 103 | description=""" 104 | 지정한 콘텐츠의 파일 리스트를 출력합니다 105 | """, 106 | ) 107 | def list(self, request, *args, **kwargs): 108 | return super().list(request, *args, **kwargs) 109 | 110 | @extend_schema( 111 | request=FileDownloadPresignedSerializer, 112 | responses={ 113 | 200: FileDownloadPresignedSerializer, 114 | }, 115 | tags=["file"], 116 | summary="파일 다운로드, 프리사인드 생성", 117 | description=""" 118 | 파일 다운로드를 위한 프리사인드 URL을 생성합니다 119 | """, 120 | ) 121 | @action( 122 | detail=True, methods=["POST"], serializer_class=FileDownloadPresignedSerializer 123 | ) 124 | def presigned(self, request, *args, **kwargs): 125 | return super().update(request, *args, **kwargs) 126 | -------------------------------------------------------------------------------- /src/apps/device/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.7 on 2025-04-09 13:01 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="Device", 19 | fields=[ 20 | ( 21 | "id", 22 | models.BigAutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("uuid", models.UUIDField(unique=True, verbose_name="UUID")), 30 | ( 31 | "platform", 32 | models.CharField( 33 | choices=[(1, "Android"), (2, "iOS"), (3, "Web")], 34 | default=3, 35 | max_length=50, 36 | verbose_name="플랫폼", 37 | ), 38 | ), 39 | ( 40 | "created_at", 41 | models.DateTimeField(auto_now_add=True, verbose_name="생성 일시"), 42 | ), 43 | ( 44 | "updated_at", 45 | models.DateTimeField(auto_now=True, verbose_name="수정 일시"), 46 | ), 47 | ( 48 | "user", 49 | models.ForeignKey( 50 | on_delete=django.db.models.deletion.CASCADE, 51 | related_name="devices", 52 | to=settings.AUTH_USER_MODEL, 53 | verbose_name="사용자", 54 | ), 55 | ), 56 | ], 57 | options={ 58 | "verbose_name": "디바이스", 59 | "verbose_name_plural": "디바이스", 60 | "db_table": "device", 61 | }, 62 | ), 63 | migrations.CreateModel( 64 | name="PushToken", 65 | fields=[ 66 | ( 67 | "id", 68 | models.BigAutoField( 69 | auto_created=True, 70 | primary_key=True, 71 | serialize=False, 72 | verbose_name="ID", 73 | ), 74 | ), 75 | ("token", models.CharField(max_length=255, verbose_name="토큰")), 76 | ( 77 | "endpoint_arn", 78 | models.CharField( 79 | blank=True, 80 | max_length=255, 81 | null=True, 82 | verbose_name="엔드포인트 ARN", 83 | ), 84 | ), 85 | ( 86 | "is_valid", 87 | models.BooleanField(default=True, verbose_name="유효 여부"), 88 | ), 89 | ( 90 | "created_at", 91 | models.DateTimeField(auto_now_add=True, verbose_name="생성 일시"), 92 | ), 93 | ( 94 | "updated_at", 95 | models.DateTimeField(auto_now=True, verbose_name="수정 일시"), 96 | ), 97 | ( 98 | "device", 99 | models.ForeignKey( 100 | on_delete=django.db.models.deletion.CASCADE, 101 | related_name="push_tokens", 102 | to="device.device", 103 | verbose_name="디바이스", 104 | ), 105 | ), 106 | ], 107 | options={ 108 | "verbose_name": "푸시 토큰", 109 | "verbose_name_plural": "푸시 토큰", 110 | "db_table": "push_token", 111 | "unique_together": {("device", "token")}, 112 | }, 113 | ), 114 | ] 115 | -------------------------------------------------------------------------------- /src/apps/agreement/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.html import format_html 3 | 4 | from .models import Agreement, UserAgreement 5 | 6 | 7 | @admin.register(Agreement) 8 | class AgreementAdmin(admin.ModelAdmin): 9 | """ 10 | 약관 정보 Admin 설정 11 | - 생성: 가능 12 | - 수정: 불가능 (UI상 readonly 처리 + 모델 save 메서드에서 방지) 13 | - 삭제: 불가능 14 | """ 15 | 16 | list_display = ( 17 | "title", 18 | "version", 19 | "get_agreement_type_display", 20 | "order", 21 | "is_required", 22 | "is_active", 23 | "previous_version_link", 24 | "created_at", 25 | ) 26 | list_filter = ("agreement_type", "is_required", "is_active") 27 | search_fields = ("title", "version", "content") 28 | ordering = ("-created_at",) # 최신순 정렬 29 | 30 | fields = ( 31 | "title", 32 | "content", 33 | "version", 34 | "previous_version", 35 | "agreement_type", 36 | "order", 37 | "is_required", 38 | "is_active", 39 | ) 40 | 41 | def get_readonly_fields(self, request, obj=None): 42 | """ 43 | 객체가 이미 존재하면 (수정 시도 시) 모든 필드를 읽기 전용으로 만듭니다. 44 | """ 45 | if obj: 46 | # 모든 모델 필드를 읽기 전용으로 반환 47 | return [field.name for field in self.opts.model._meta.fields] 48 | return [] 49 | 50 | def has_change_permission(self, request, obj=None): 51 | """ 52 | 수정 권한 제어 (UI상 버튼 비활성화 등). get_readonly_fields와 함께 사용. 53 | 모델 레벨에서도 막고 있지만, UI 상에서도 명확히 하기 위함. 54 | """ 55 | if obj: 56 | return False 57 | return super().has_change_permission(request, obj) 58 | 59 | def has_delete_permission(self, request, obj=None): 60 | """ 61 | 삭제 권한을 비활성화합니다. 62 | """ 63 | return False 64 | 65 | def get_agreement_type_display(self, obj): 66 | """ 67 | IntegerField + Choices 필드의 display 값을 list_display에 보여줍니다. 68 | """ 69 | return obj.get_agreement_type_display() 70 | 71 | get_agreement_type_display.short_description = "약관 타입" 72 | 73 | def previous_version_link(self, obj): 74 | """ 75 | 이전 버전 약관 관리자 페이지로 바로 이동하는 링크 (선택 사항) 76 | """ 77 | if obj.previous_version: 78 | link = f"/admin/{obj._meta.app_label}/{obj._meta.model_name}/{obj.previous_version.pk}/change/" 79 | return format_html( 80 | '{} (v{})', 81 | link, 82 | obj.previous_version.title, 83 | obj.previous_version.version, 84 | ) 85 | return "-" 86 | 87 | previous_version_link.short_description = "이전 버전" 88 | 89 | 90 | @admin.register(UserAgreement) 91 | class UserAgreementAdmin(admin.ModelAdmin): 92 | """ 93 | 사용자 약관 동의 정보 Admin 설정 94 | - 생성: 불가능 95 | - 수정: 불가능 96 | - 삭제: 불가능 97 | - 조회: 가능 (사용자, 약관 타입 등으로 필터링 가능) 98 | """ 99 | 100 | list_display = ( 101 | "user", 102 | "get_agreement_info", 103 | "is_agreed", 104 | "created_at", 105 | ) 106 | list_filter = ("is_agreed", "agreement__agreement_type") 107 | search_fields = ( 108 | "user__username", 109 | "agreement__title", 110 | "agreement__version", 111 | ) 112 | ordering = ("-created_at",) 113 | 114 | readonly_fields = ("user", "agreement", "is_agreed", "created_at") 115 | 116 | def has_add_permission(self, request): 117 | """ 118 | 생성 권한을 비활성화합니다. 119 | """ 120 | return False 121 | 122 | def has_change_permission(self, request, obj=None): 123 | """ 124 | 수정 권한을 비활성화합니다. (readonly_fields 설정으로도 충분하지만 명시적으로 추가) 125 | """ 126 | return False 127 | 128 | def has_delete_permission(self, request, obj=None): 129 | """ 130 | 삭제 권한을 비활성화합니다. 131 | """ 132 | return False 133 | 134 | def get_agreement_info(self, obj): 135 | """ 136 | 연결된 약관의 제목과 버전을 함께 보여줍니다. 137 | """ 138 | if obj.agreement: 139 | return f"{obj.agreement.title} (v{obj.agreement.version})" 140 | return "-" 141 | 142 | get_agreement_info.short_description = "동의한 약관" 143 | -------------------------------------------------------------------------------- /src/apps/short_url/v1/serializers.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | from rest_framework import serializers 4 | 5 | from apps.short_url.models import ShortUrl, ShortUrlVisit 6 | from apps.short_url.v1.utils import generate_random_key, id_to_key 7 | from base.enums.errors import ( 8 | E005_HASHED_VALUE_ALREADY_EXISTS, 9 | E005_INVALID_OG_TAG_FORMAT, 10 | ) 11 | 12 | 13 | class ShortUrlSerializer(serializers.ModelSerializer): 14 | """ 15 | 단축 URL 시리얼라이저: 16 | 입력된 정보등을 이용해서 존재 여부 확인 17 | 특별한 방식으로 중복되지 않는 고유한 값 생성 18 | 항상 빠르고 안전하게 생성하고 관리 19 | """ 20 | 21 | random_key = serializers.HiddenField( 22 | default=generate_random_key, 23 | help_text="랜덤 키", 24 | ) 25 | hashed_value = serializers.HiddenField( 26 | default="", 27 | help_text="해시값", 28 | ) 29 | short_key = serializers.CharField( 30 | read_only=True, 31 | help_text="단축 URL", 32 | ) 33 | 34 | def validate_og_tag(self, value): 35 | # OG 태그는 JSON 형식 36 | if value and not isinstance(value, dict): 37 | raise serializers.ValidationError(E005_INVALID_OG_TAG_FORMAT) 38 | return value 39 | 40 | def validate(self, attrs): 41 | # hashed_value 값 생성 42 | concatenated = "".join( 43 | [ 44 | attrs.get("ios_deep_link", ""), 45 | attrs.get("ios_fallback_url", ""), 46 | attrs.get("android_deep_link", ""), 47 | attrs.get("android_fallback_url", ""), 48 | attrs.get("default_fallback_url", ""), 49 | ] 50 | ) 51 | 52 | # SHA-256 해시 생성 53 | # [Why] 54 | # Q. 왜 SHA-256 해시를 사용하는가? 55 | # A. SHA-256은 보안성이 높고, 해시 충돌 가능성이 낮기 때문 56 | # 또한, 해시값이 고유해야 하므로, 단축 URL 생성 시 중복 체크를 위해 사용 57 | hasher = hashlib.sha256() 58 | hasher.update(concatenated.encode("utf-8")) 59 | hashed_value = hasher.hexdigest() 60 | 61 | if ShortUrl.objects.filter(hashed_value=hashed_value).exists(): 62 | raise serializers.ValidationError(E005_HASHED_VALUE_ALREADY_EXISTS) 63 | attrs["hashed_value"] = hashed_value 64 | return attrs 65 | 66 | def to_representation(self, instance): 67 | # ShortURL 의 Key 값 생성 68 | short_key = id_to_key(instance.id) 69 | random_key = instance.random_key 70 | # [Why] 71 | # Q. 왜 랜덤 키와 PK에 대한 인덱스를 사용하여 단축 URL을 생성하는가? 72 | # A. 랜덤 키는 고유성을 보장하고, PK는 데이터베이스에서의 유일성을 보장하기 때문 73 | # 두 값을 조합할 경우 언제나 고유한 단축 URL을 생성할 수 있음 74 | instance.short_key = f"{random_key[:2]}{short_key}{random_key[2:]}" 75 | return super().to_representation(instance) 76 | 77 | class Meta: 78 | model = ShortUrl 79 | fields = [ 80 | "id", 81 | "short_key", 82 | "random_key", 83 | "ios_deep_link", 84 | "ios_fallback_url", 85 | "android_deep_link", 86 | "android_fallback_url", 87 | "default_fallback_url", 88 | "hashed_value", 89 | "og_tag", 90 | ] 91 | 92 | 93 | class ShortUrlRedirectSerializer(serializers.ModelSerializer): 94 | """ 95 | 단축 URL 리다이렉트 시리얼라이저: 96 | 숏링크에 해당하는 원본 링크로 리다이렉트 97 | 방문 기록 저장 98 | """ 99 | 100 | def save(self, **kwargs): 101 | # 요청 객체에서 필요한 정보 추출 102 | request = self.context.get("request") 103 | 104 | # IP 주소 추출 105 | x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") 106 | if x_forwarded_for: 107 | # X-Forwarded-For가 있으면 첫 번째 IP(실제 클라이언트 IP)만 사용 108 | ip_address = x_forwarded_for.split(",")[0].strip() 109 | else: 110 | # 없으면 REMOTE_ADDR 사용 111 | ip_address = request.META.get("REMOTE_ADDR") 112 | 113 | # 추가 데이터 추출 114 | user_agent = request.META.get("HTTP_USER_AGENT") 115 | referrer = request.query_params.get("referrer") 116 | 117 | # 방문 기록 생성 118 | ShortUrlVisit.objects.create( 119 | short_url=self.instance, 120 | referrer=referrer, 121 | user_agent=user_agent, 122 | ip_address=ip_address, 123 | ) 124 | 125 | class Meta: 126 | model = ShortUrl 127 | fields = [ 128 | "id", 129 | "ios_deep_link", 130 | "ios_fallback_url", 131 | "android_deep_link", 132 | "android_fallback_url", 133 | "default_fallback_url", 134 | "hashed_value", 135 | "og_tag", 136 | ] 137 | -------------------------------------------------------------------------------- /src/apps/short_url/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.7 on 2025-04-08 05:57 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="ShortUrl", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("random_key", models.CharField(max_length=4, verbose_name="랜덤 키")), 27 | ( 28 | "ios_deep_link", 29 | models.CharField( 30 | blank=True, max_length=255, null=True, verbose_name="iOS 딥링크" 31 | ), 32 | ), 33 | ( 34 | "ios_fallback_url", 35 | models.URLField(blank=True, null=True, verbose_name="iOS 폴백 URL"), 36 | ), 37 | ( 38 | "android_deep_link", 39 | models.CharField( 40 | blank=True, 41 | max_length=255, 42 | null=True, 43 | verbose_name="안드로이드 딥링크", 44 | ), 45 | ), 46 | ( 47 | "android_fallback_url", 48 | models.URLField( 49 | blank=True, null=True, verbose_name="안드로이드 폴백 URL" 50 | ), 51 | ), 52 | ("default_fallback_url", models.URLField(verbose_name="기본 폴백 URL")), 53 | ( 54 | "hashed_value", 55 | models.CharField( 56 | db_index=True, max_length=64, verbose_name="해시값" 57 | ), 58 | ), 59 | ( 60 | "og_tag", 61 | models.JSONField(blank=True, null=True, verbose_name="OG 태그"), 62 | ), 63 | ( 64 | "created_at", 65 | models.DateTimeField(auto_now_add=True, verbose_name="생성 일시"), 66 | ), 67 | ], 68 | options={ 69 | "verbose_name": "단축 URL", 70 | "verbose_name_plural": "단축 URL", 71 | "db_table": "short_url", 72 | }, 73 | ), 74 | migrations.CreateModel( 75 | name="ShortUrlVisit", 76 | fields=[ 77 | ( 78 | "id", 79 | models.BigAutoField( 80 | auto_created=True, 81 | primary_key=True, 82 | serialize=False, 83 | verbose_name="ID", 84 | ), 85 | ), 86 | ( 87 | "referrer", 88 | models.CharField( 89 | blank=True, max_length=255, null=True, verbose_name="레퍼러" 90 | ), 91 | ), 92 | ( 93 | "user_agent", 94 | models.TextField( 95 | blank=True, null=True, verbose_name="사용자 에이전트" 96 | ), 97 | ), 98 | ( 99 | "ip_address", 100 | models.GenericIPAddressField( 101 | blank=True, null=True, verbose_name="IP 주소" 102 | ), 103 | ), 104 | ( 105 | "created_at", 106 | models.DateTimeField(auto_now_add=True, verbose_name="생성 일시"), 107 | ), 108 | ( 109 | "short_url", 110 | models.ForeignKey( 111 | on_delete=django.db.models.deletion.CASCADE, 112 | to="short_url.shorturl", 113 | verbose_name="단축 URL", 114 | ), 115 | ), 116 | ], 117 | options={ 118 | "verbose_name": "단축 URL 방문", 119 | "verbose_name_plural": "단축 URL 방문", 120 | "db_table": "short_url_visit", 121 | }, 122 | ), 123 | ] 124 | -------------------------------------------------------------------------------- /src/apps/file/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.7 on 2025-04-08 05:57 2 | 3 | import django.db.models.deletion 4 | import uuid_extensions.uuid7 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ("contenttypes", "0002_remove_content_type_name"), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="FileDownloadHistory", 19 | fields=[ 20 | ( 21 | "id", 22 | models.BigAutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ( 30 | "reason", 31 | models.CharField( 32 | blank=True, 33 | max_length=100, 34 | null=True, 35 | verbose_name="다운로드 사유", 36 | ), 37 | ), 38 | ( 39 | "created_at", 40 | models.DateTimeField(auto_now_add=True, verbose_name="생성일시"), 41 | ), 42 | ], 43 | options={ 44 | "verbose_name": "파일 다운로드 이력", 45 | "verbose_name_plural": "파일 다운로드 이력", 46 | "db_table": "file_download_history", 47 | }, 48 | ), 49 | migrations.CreateModel( 50 | name="File", 51 | fields=[ 52 | ( 53 | "uuid", 54 | models.UUIDField( 55 | default=uuid_extensions.uuid7, 56 | editable=False, 57 | primary_key=True, 58 | serialize=False, 59 | verbose_name="저장에 사용되는 파일 ID", 60 | ), 61 | ), 62 | ( 63 | "file_name", 64 | models.CharField(max_length=100, verbose_name="실제 파일명"), 65 | ), 66 | ( 67 | "extension", 68 | models.CharField(max_length=10, verbose_name="파일 확장자"), 69 | ), 70 | ("file_size", models.IntegerField(default=0, verbose_name="파일 크기")), 71 | ( 72 | "object_key", 73 | models.TextField(blank=True, null=True, verbose_name="파일 URL"), 74 | ), 75 | ( 76 | "object_id", 77 | models.CharField( 78 | max_length=36, null=True, verbose_name="컨텐츠 ID" 79 | ), 80 | ), 81 | ( 82 | "status", 83 | models.PositiveSmallIntegerField( 84 | choices=[ 85 | (1, "준비"), 86 | (2, "생성 완료"), 87 | (3, "업로드 성공"), 88 | (4, "업로드 실패"), 89 | (5, "삭제"), 90 | ], 91 | default=1, 92 | verbose_name="파일 상태", 93 | ), 94 | ), 95 | ( 96 | "expire_at", 97 | models.DateTimeField( 98 | blank=True, db_index=True, null=True, verbose_name="만료일시" 99 | ), 100 | ), 101 | ( 102 | "created_at", 103 | models.DateTimeField(auto_now_add=True, verbose_name="생성 일시"), 104 | ), 105 | ( 106 | "updated_at", 107 | models.DateTimeField(auto_now=True, verbose_name="수정 일시"), 108 | ), 109 | ( 110 | "content_type", 111 | models.ForeignKey( 112 | null=True, 113 | on_delete=django.db.models.deletion.SET_NULL, 114 | related_name="files", 115 | to="contenttypes.contenttype", 116 | verbose_name="컨텐츠 타입", 117 | ), 118 | ), 119 | ], 120 | options={ 121 | "verbose_name": "파일", 122 | "verbose_name_plural": "파일", 123 | "db_table": "file", 124 | }, 125 | ), 126 | ] 127 | -------------------------------------------------------------------------------- /src/apps/agreement/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from apps.agreement.v1.tasks import task_send_re_agreement_notification 4 | from apps.user.models import User 5 | 6 | 7 | class AgreementType(models.IntegerChoices): 8 | """약관 타입""" 9 | 10 | SERVICES = 1, "서비스 약관" 11 | PRIVACY = 2, "개인정보 처리 방침" 12 | MARKETING = 3, "마케팅 약관" 13 | 14 | 15 | class Agreement(models.Model): 16 | """약관 정보""" 17 | 18 | title = models.CharField( 19 | max_length=100, 20 | verbose_name="약관 제목", 21 | ) 22 | content = models.TextField( 23 | verbose_name="약관 내용", 24 | ) 25 | version = models.CharField( 26 | max_length=20, 27 | verbose_name="약관 버전", 28 | ) 29 | previous_version = models.ForeignKey( 30 | "self", 31 | on_delete=models.SET_NULL, 32 | null=True, 33 | blank=True, 34 | related_name="next_versions", 35 | verbose_name="이전 버전", 36 | help_text="이 약관 개정 전의 이전 버전 약관을 참조합니다.", 37 | ) 38 | agreement_type = models.IntegerField( 39 | choices=AgreementType.choices, 40 | verbose_name="약관 타입", 41 | help_text="1: 서비스 약관, 2: 개인정보 처리 방침, 3: 마케팅 약관", 42 | ) 43 | order = models.IntegerField( 44 | default=0, 45 | verbose_name="약관 순서", 46 | help_text="약관 목록에서 표시되는 순서입니다.", 47 | ) 48 | is_required = models.BooleanField( 49 | default=False, 50 | verbose_name="필수 동의 여부", 51 | help_text="사용자가 동의해야 하는 필수 약관인지 여부를 나타냅니다.", 52 | ) 53 | is_active = models.BooleanField( 54 | default=True, 55 | verbose_name="활성화 여부", 56 | ) 57 | created_at = models.DateTimeField( 58 | auto_now_add=True, 59 | verbose_name="생성 일시", 60 | ) 61 | 62 | def save(self, *args, **kwargs): 63 | # 수정 불가 64 | # [Why] 65 | # Q. 수정을 막는 이유는? 66 | # A. 약관은 법적 효력이 있는 문서이기 때문에, 수정 시에는 새로운 버전으로 등록해야 합니다. 67 | if self.pk: 68 | raise ValueError("약관은 수정할 수 없습니다. 새로운 버전으로 등록하세요.") 69 | 70 | # 이전 버전이 존재하고 활성화된 경우 비활성화 71 | # [Why] 72 | # Q. 약관 개정 시 이전 버전은 비활성화해야 하는가? 73 | # A. 예, 약관 개정 시 이전 버전은 비활성화해야함 74 | # 사용자가 이전 버전의 약관에 동의한 경우에도, 새로운 약관이 적용되도록 하기 위함 75 | if self.previous_version and self.is_active and self.previous_version.is_active: 76 | self.previous_version.is_active = False 77 | self.previous_version.save() 78 | 79 | # 이미 해당 예전 약관을 동의한 사람들에게 재동의 요청 푸시/이메일 발송 예약 80 | # [Why] 81 | # Q. 왜 재동의 요청을 해야 하는가? 82 | # A. 약관은 법적 효력이 있는 문서이기 때문에, 개정 시에는 사용자에게 재동의 요청을 해야함 83 | task_send_re_agreement_notification.apply_async( 84 | args=[self.previous_version, self], 85 | ) 86 | super().save(*args, **kwargs) 87 | 88 | class Meta: 89 | db_table = "agreement" 90 | verbose_name = "약관 정보" 91 | verbose_name_plural = "약관 정보" 92 | 93 | 94 | class UserAgreement(models.Model): 95 | """사용자 약관 동의 정보""" 96 | 97 | user = models.ForeignKey( 98 | User, 99 | on_delete=models.CASCADE, 100 | related_name="user_agreements", 101 | verbose_name="사용자", 102 | ) 103 | agreement = models.ForeignKey( 104 | Agreement, 105 | on_delete=models.CASCADE, 106 | related_name="user_agreements", 107 | verbose_name="약관", 108 | ) 109 | is_agreed = models.BooleanField( 110 | default=False, 111 | verbose_name="동의 여부", 112 | ) 113 | created_at = models.DateTimeField( 114 | auto_now_add=True, 115 | verbose_name="생성 일시", 116 | ) 117 | updated_at = models.DateTimeField( 118 | auto_now=True, 119 | verbose_name="수정 일시", 120 | ) 121 | 122 | class Meta: 123 | db_table = "user_agreement" 124 | verbose_name = "사용자 약관 동의 정보" 125 | verbose_name_plural = "사용자 약관 동의 정보" 126 | unique_together = ["user", "agreement"] 127 | 128 | 129 | class UserAgreementHistory(models.Model): 130 | """ 131 | 사용자 약관 동의 이력: 132 | 약관 정보가 변경될 경우 어떤 값이 어떻게 변경되었는지에 대한 기록 필요 133 | """ 134 | 135 | user_agreement = models.ForeignKey( 136 | UserAgreement, 137 | on_delete=models.CASCADE, 138 | related_name="history", 139 | verbose_name="사용자 약관 동의 정보", 140 | ) 141 | data = models.JSONField( 142 | verbose_name="변경 전 사용자 약관 동의 정보", 143 | help_text="변경 전 사용자 약관 동의 정보(변경 일시, 동의 여부, 약관 정보 등)", 144 | ) 145 | created_at = models.DateTimeField( 146 | auto_now_add=True, 147 | verbose_name="생성 일시", 148 | ) 149 | 150 | class Meta: 151 | db_table = "user_agreement_history" 152 | verbose_name = "사용자 약관 동의 이력" 153 | verbose_name_plural = "사용자 약관 동의 이력" 154 | -------------------------------------------------------------------------------- /src/apps/file/v1/serializers.py: -------------------------------------------------------------------------------- 1 | from django.utils import timezone 2 | from rest_framework import serializers 3 | 4 | from apps.file.models import File, FileStatus, FileDownloadHistory 5 | from apps.file.v1.utils import ( 6 | generate_upload_presigned_url, 7 | generate_download_presigned_url, 8 | ) 9 | 10 | 11 | class FileUploadSerializer(serializers.ModelSerializer): 12 | """ 13 | 파일 업로드 시리얼라이저: 14 | 파일 업로드 시 직접 업로드하지 않고 프리사인드를 이용해서 업로드 15 | """ 16 | 17 | user = serializers.HiddenField( 18 | default=serializers.CurrentUserDefault(), help_text="사용자" 19 | ) 20 | url = serializers.CharField( 21 | read_only=True, 22 | help_text="S3 업로드 URL", 23 | allow_null=True, 24 | ) 25 | fields = serializers.JSONField( 26 | read_only=True, 27 | help_text="S3 업로드 필드", 28 | allow_null=True, 29 | ) 30 | 31 | def create(self, validated_data): 32 | instance = super().create(validated_data) 33 | # S3 파일 경로 저장 34 | content_type = "" 35 | file_name = f"{instance.uuid}.{instance.extension}" 36 | now = timezone.now() 37 | object_key = ( 38 | f"files/{content_type}/{now.year}/{now.month}/{now.day}/{file_name}" 39 | ) 40 | # 프리사인드 정보 추가 41 | # [Why] 42 | # Q. S3 업로드 시 프리사인드 URL을 생성하는 이유는? 43 | # A. 아무나, 어떤 파일이든 업로드할 수 없도록 하기 위함 44 | # 프리사인드를 통해 권한이 있는 사용자만 업로드할 수 있도록 함 45 | # 또한, 자신의 파일만 업로드할 수 있도록 하기 위함 46 | response = generate_upload_presigned_url( 47 | object_key=object_key, 48 | file_size=instance.file_size, 49 | ) 50 | instance.object_key = object_key 51 | instance.status = FileStatus.GENERATED 52 | instance.save() 53 | instance.fields = response.get("fields") 54 | instance.url = response.get("url") 55 | return instance 56 | 57 | class Meta: 58 | model = File 59 | fields = [ 60 | "uuid", 61 | "user", 62 | "file_name", 63 | "extension", 64 | "file_size", 65 | "content_type", 66 | "object_id", 67 | "status", 68 | "url", 69 | "fields", 70 | ] 71 | read_only_fields = [ 72 | "uuid", 73 | ] 74 | extra_kwargs = { 75 | "user": {"write_only": True}, 76 | "content_type": {"write_only": True}, 77 | "object_id": {"write_only": True}, 78 | } 79 | 80 | 81 | class FileUpdateSerializer(serializers.ModelSerializer): 82 | """ 83 | 파일 업데이트 시리얼라이저: 84 | 파일 업로드 후 상태 업데이트, 삭제 요청 85 | """ 86 | 87 | class Meta: 88 | model = File 89 | fields = ["status"] 90 | 91 | 92 | class FileDownloadSerializer(serializers.ModelSerializer): 93 | """ 94 | 파일 다운로드 시리얼라이저: 95 | 파일 다운로드 가능한 파일 리스트 조회 96 | """ 97 | 98 | class Meta: 99 | model = File 100 | fields = [ 101 | "uuid", 102 | "file_name", 103 | "extension", 104 | "file_size", 105 | "content_type", 106 | "object_id", 107 | "status", 108 | "expire_at", 109 | "created_at", 110 | ] 111 | 112 | 113 | class FileDownloadPresignedSerializer(serializers.ModelSerializer): 114 | """ 115 | 파일 다운로드 프리사인드 시리얼라이저: 116 | 파일 다운로드 시 프리사인드를 이용해서 다운로드 117 | """ 118 | 119 | reason = serializers.CharField( 120 | write_only=True, 121 | help_text="다운로드 사유", 122 | ) 123 | url = serializers.CharField( 124 | read_only=True, 125 | help_text="S3 업로드 URL", 126 | allow_null=True, 127 | ) 128 | fields = serializers.JSONField( 129 | read_only=True, 130 | help_text="S3 업로드 필드", 131 | allow_null=True, 132 | ) 133 | 134 | def update(self, instance, validated_data): 135 | """파일 다운로드 프리사인드 URL 생성""" 136 | # [Why] 137 | # Q. 파일 다운로드 시 프리사인드 URL을 생성하는 이유는? 138 | # A. 아무나, 어떤 파일이든 다운로드할 수 없도록 하기 위함 139 | # 프리사인드 URL을 통해 권한이 있는 사용자만 다운로드할 수 있도록 하기 위함 140 | # 참고로, 모든 사람이 받아도 되는 파일은 CDN 을 통해 다운로드 가능하도록 설정 141 | response = generate_download_presigned_url( 142 | object_key=instance.object_key, 143 | ) 144 | instance.fields = response.get("fields") 145 | instance.url = response.get("url") 146 | # 파일 다운로드 기록 147 | FileDownloadHistory.objects.create( 148 | reason=validated_data.get("reason"), 149 | user=self.context["request"].user, 150 | file=instance, 151 | ) 152 | return instance 153 | 154 | class Meta: 155 | model = File 156 | fields = [ 157 | "reason", 158 | "url", 159 | "fields", 160 | "file_name", 161 | "extension", 162 | ] 163 | read_only_fields = [ 164 | "file_name", 165 | "extension", 166 | ] 167 | -------------------------------------------------------------------------------- /docs/en/deploy/docker-setup.md: -------------------------------------------------------------------------------- 1 | ## 🐳 Development and Deployment with Docker 2 | 3 | ### Local Development Environment Setup 4 | 5 | Docker and Docker Compose make it easy to configure complex development environments. This project uses Docker Compose to set up the entire stack, including the web server, Celery worker, PostgreSQL, and Redis. 6 | 7 | #### Prerequisites 8 | 9 | - Install [Docker](https://docs.docker.com/get-docker/) 10 | - Install [Docker Compose](https://docs.docker.com/compose/install/) 11 | 12 | #### Running the Development Environment 13 | 14 | ```bash 15 | # From the project root directory 16 | docker-compose up -d 17 | 18 | # Check logs 19 | docker-compose logs -f 20 | 21 | # Check logs for a specific service 22 | docker-compose logs -f web 23 | ``` 24 | 25 | #### Accessing the Development Environment 26 | 27 | - Django web server: http://localhost:8000 28 | - Database: PostgreSQL (localhost:5432) 29 | - Redis: localhost:6379 30 | 31 | #### Running Management Commands 32 | 33 | ```bash 34 | # Execute Django management commands 35 | docker-compose exec web python manage.py [command] 36 | 37 | # Example: Generate migrations 38 | docker-compose exec web python manage.py makemigrations 39 | 40 | # Example: Create a superuser 41 | docker-compose exec web python manage.py createsuperuser 42 | ``` 43 | 44 | ### On-premises/Server Deployment 45 | 46 | #### Production Environment Setup 47 | 48 | 1. Setting up production environment variables 49 | 50 | ```bash 51 | # Create .env file 52 | cp src/.env.example src/.env 53 | 54 | # Modify the .env file for the production environment 55 | # Be sure to change the following variables: 56 | # - SECRET_KEY: Set to a long random string for security 57 | # - DEBUG: Set to False 58 | # - ALLOWED_HOSTS: Set to your actual domain name 59 | # - DATABASE_URL: Production database connection information 60 | ``` 61 | 62 | 2. Optimize docker-compose.yml (optional) 63 | 64 | ```bash 65 | # Create a production docker-compose file 66 | cp docker-compose.yml docker-compose.prod.yml 67 | 68 | # Edit docker-compose.prod.yml: 69 | # - Optimize volume settings 70 | # - Change environment variable DJANGO_SETTINGS_MODULE to conf.settings.production 71 | # - Adjust the number of workers and threads 72 | ``` 73 | 74 | 3. Run deployment 75 | 76 | ```bash 77 | # Production deployment 78 | docker-compose -f docker-compose.prod.yml up -d 79 | 80 | # Run migrations 81 | docker-compose -f docker-compose.prod.yml exec web python manage.py migrate 82 | 83 | # Collect static files 84 | docker-compose -f docker-compose.prod.yml exec web python manage.py collectstatic --noinput 85 | ``` 86 | 87 | #### NGINX Proxy Configuration (Recommended) 88 | 89 | In a production environment, it's recommended to use NGINX as a front-end to handle static file serving and SSL termination. 90 | 91 | ```nginx 92 | server { 93 | listen 80; 94 | server_name your-domain.com; 95 | 96 | location /static/ { 97 | alias /path/to/your/static/files/; 98 | } 99 | 100 | location /media/ { 101 | alias /path/to/your/media/files/; 102 | } 103 | 104 | location / { 105 | proxy_pass http://localhost:8000; 106 | proxy_set_header Host $host; 107 | proxy_set_header X-Real-IP $remote_addr; 108 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 109 | proxy_set_header X-Forwarded-Proto $scheme; 110 | } 111 | } 112 | ``` 113 | 114 | ### Docker Container Management 115 | 116 | #### Basic Commands 117 | 118 | ```bash 119 | # Start the entire stack 120 | docker-compose up -d 121 | 122 | # Stop the entire stack 123 | docker-compose down 124 | 125 | # Restart a specific service 126 | docker-compose restart web 127 | 128 | # Check container logs 129 | docker-compose logs -f [service_name] 130 | 131 | # Access container shell 132 | docker-compose exec [service_name] bash 133 | ``` 134 | 135 | #### Database Backup and Restore 136 | 137 | ```bash 138 | # PostgreSQL database backup 139 | docker-compose exec db pg_dump -U postgres postgres > backup.sql 140 | 141 | # PostgreSQL database restore 142 | cat backup.sql | docker-compose exec -T db psql -U postgres postgres 143 | ``` 144 | 145 | #### Resource Monitoring 146 | 147 | ```bash 148 | # Check running containers 149 | docker-compose ps 150 | 151 | # Check container resource usage 152 | docker stats 153 | ``` 154 | 155 | ### Scaling and Performance Optimization 156 | 157 | #### Web Application Scaling 158 | 159 | ```bash 160 | # Adjust the number of web service instances 161 | docker-compose up -d --scale web=3 162 | ``` 163 | 164 | #### Worker Scaling 165 | 166 | ```bash 167 | # Adjust the number of Celery worker instances 168 | docker-compose up -d --scale worker=5 169 | ``` 170 | 171 | #### Gunicorn Configuration Optimization 172 | 173 | To optimize web service performance, adjust Gunicorn settings in Dockerfile or docker-compose.yml: 174 | 175 | ``` 176 | # Number of workers = (2 * CPU cores) + 1 177 | # Number of threads = 2-4 178 | CMD ["gunicorn", "--bind", "0.0.0.0:8000", "conf.wsgi:application", "--workers", "5", "--threads", "2"] 179 | ``` 180 | -------------------------------------------------------------------------------- /src/templates/otp/setup_otp.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% block extrahead %} 4 | 110 | {% endblock %} 111 | 112 | {% block content %} 113 |
114 |

{% translate 'Set up your OTP' %}

115 | 116 |
117 |

{% translate 'Scan the QR code with your authenticator app' %}

118 | QR Code 120 |
121 | 122 |

{% translate 'Enter the 6-digit code from your app' %}

123 | 124 |
125 | {% csrf_token %} 126 |
127 | 128 | 137 |
138 | 139 | 142 |
143 | 144 | {% if messages %} 145 | {% for message in messages %} 146 |
{{ message }}
147 | {% endfor %} 148 | {% endif %} 149 |
150 | 151 | 161 | {% endblock %} -------------------------------------------------------------------------------- /src/apps/account/v1/adapters.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import secrets 4 | 5 | from django.conf import settings 6 | from google.auth.transport import requests as google_requests 7 | from google.oauth2 import id_token 8 | from google_auth_oauthlib.flow import Flow 9 | from rest_framework import exceptions 10 | 11 | from apps.user.models import User, SocialUser, UserProfile 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class SocialLoginAdapter: 17 | """ 18 | 소셜 로그인 어댑터의 기본 클래스: 19 | 현재 구글 로그인만 구현된 상태 20 | 추후 다른 소셜 로그인이 추가될 경우 해당 클래스를 상속받아 구현 21 | """ 22 | 23 | def __init__(self, request): 24 | self.request = request 25 | self.provider = None 26 | 27 | def get_redirect_uri(self): 28 | raise NotImplementedError("Subclasses must implement this method") 29 | 30 | def get_token(self, code: str): 31 | raise NotImplementedError("Subclasses must implement this method") 32 | 33 | def get_user_data(self, credentials): 34 | raise NotImplementedError("Subclasses must implement this method") 35 | 36 | def process_user(self, user_data: dict): 37 | raise NotImplementedError("Subclasses must implement this method") 38 | 39 | 40 | class GoogleLoginAdapter(SocialLoginAdapter): 41 | """ 42 | Google 소셜 로그인 어댑터: 43 | 구글 로그인에 필요한 최소한만 구현하기 위해 아래와 같이 구현 44 | 구글 로그인 페이지로 이동 45 | 로그인 결과로 반환 받은 code 를 이용해서 토큰 생성 및 사용자 정보 조회 46 | 조회된 내용으로 계정 생성 등을 진행 47 | 계정 생성 시 이메일을 기준으로 생성하며 인증 여부에 따라 다르게 처리 48 | """ 49 | 50 | # Google OAuth2 클라이언트 설정 51 | FROM_CLIENT_CONFIG = { 52 | "client_config": settings.GOOGLE_CLIENT_SECRETS_CONFIG, 53 | "scopes": [ 54 | "openid", 55 | "https://www.googleapis.com/auth/userinfo.email", 56 | "https://www.googleapis.com/auth/userinfo.profile", 57 | ], 58 | "redirect_uri": settings.GOOGLE_REDIRECT_URI, 59 | } 60 | 61 | def __init__(self, request): 62 | super().__init__(request) 63 | self.provider = "google" 64 | 65 | def get_redirect_uri(self): 66 | flow = Flow.from_client_config(**self.FROM_CLIENT_CONFIG) 67 | # CSRF 방지 state 생성 및 세션 저장 68 | state = secrets.token_urlsafe(16) 69 | self.request.session["oauth_state"] = state 70 | return flow.authorization_url(access_type="offline", state=state)[0] 71 | 72 | def get_token(self, code: str): 73 | """구글 토큰 발급""" 74 | flow = Flow.from_client_config(**self.FROM_CLIENT_CONFIG) 75 | flow.fetch_token(code=code) 76 | return flow.credentials 77 | 78 | def get_user_data(self, credentials) -> dict: 79 | """사용자 조회""" 80 | id_info = id_token.verify_oauth2_token( 81 | credentials.id_token, 82 | google_requests.Request(), 83 | settings.GOOGLE_CLIENT_ID, 84 | ) 85 | # 필수 데이터 확인 86 | if not id_info.get("email"): 87 | raise exceptions.APIException("Email not found in ID token") 88 | if not id_info.get("email_verified"): 89 | id_info["email_verified"] = False 90 | return id_info 91 | 92 | def process_user(self, user_data: dict): 93 | """조회된 사용자에 대한 처리""" 94 | email = user_data["email"] 95 | email_verified = user_data["email_verified"] 96 | 97 | # 사용자 생성/조회 98 | # [Why] 99 | # Q. 이메일 인증 여부를 확인하는 이유? 100 | # A. 이메일 인증 여부에 따라 사용자의 가입 상태를 결정하기 위함 101 | # Q. get_or_create 를 사용하는 이유? 102 | # A. 소셜, 기본 방식으로 이미 가입된 경우 생성하지 않고 사용자 조회 103 | user, is_create = User.objects.get_or_create( 104 | email=email, 105 | defaults={"is_verified": bool(email_verified)}, 106 | ) 107 | # 소셜 사용자 생성 또는 업데이트 108 | # [Why] 109 | # Q. 소셜 사용자를 생성하는 이유? 110 | # A. 소셜 로그인 가입 여부를 확인하고 소셜 사용자 정보를 저장하기 위함 111 | SocialUser.objects.get_or_create( 112 | user=user, 113 | provider=self.provider, 114 | social_id=user_data["sub"], 115 | defaults={"user_data": json.dumps(user_data)}, 116 | ) 117 | # 사용자 프로필 생성 또는 업데이트 118 | # [Why] 119 | # Q. 사용자 프로필을 생성하는 이유? 120 | # A. 사용자 프로필 정보를 저장하기 위함 121 | # Q. get_or_create 를 사용하는 이유? 122 | # A. 프로필이 없는 경우 신규 생성하기 위함, 있다면 생성하지 않음 123 | UserProfile.objects.get_or_create( 124 | user=user, 125 | defaults={ 126 | "nickname": user_data.get("name"), 127 | "image": user_data.get("picture"), 128 | }, 129 | ) 130 | if is_create: 131 | # 신규 생성 시 비밀번호 미사용 132 | user.set_unusable_password() 133 | user.save() 134 | return user 135 | # 이메일 인증 여부 업데이트 136 | # [Why] 137 | # Q. 이메일 인증 여부를 업데이트 하는 이유? 138 | # A. 이미 가입된 사용자 중 인증하지 않은 사용자일 경우 소셜에서 인증되어있다면 자동 인증 139 | final_verified_status = user.is_verified or email_verified 140 | if user.is_verified != final_verified_status: 141 | user.is_verified = final_verified_status 142 | user.save(update_fields=["is_verified"]) 143 | return user 144 | -------------------------------------------------------------------------------- /src/apps/cms/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from uuid_extensions import uuid7 3 | 4 | 5 | class Notice(models.Model): 6 | """공지사항""" 7 | 8 | uuid = models.UUIDField( 9 | primary_key=True, 10 | default=uuid7, 11 | editable=False, 12 | verbose_name="UUID", 13 | ) 14 | author = models.ForeignKey( 15 | "user.User", 16 | on_delete=models.CASCADE, 17 | verbose_name="작성자", 18 | help_text="공지 작성자", 19 | ) 20 | title = models.CharField( 21 | max_length=255, 22 | verbose_name="제목", 23 | help_text="제목", 24 | ) 25 | content = models.TextField( 26 | verbose_name="내용", 27 | help_text="내용", 28 | ) 29 | published_at = models.DateTimeField( 30 | verbose_name="발행 일시", 31 | help_text="공지 발행 일시", 32 | db_index=True, 33 | ) 34 | # 표시 기간 35 | start_at = models.DateTimeField( 36 | verbose_name="시작 일시", 37 | help_text="공지 시작 일시", 38 | ) 39 | end_at = models.DateTimeField( 40 | null=True, 41 | blank=True, 42 | verbose_name="종료 일시", 43 | help_text="공지 종료 일시", 44 | ) 45 | is_published = models.BooleanField( 46 | default=False, 47 | verbose_name="발행 여부", 48 | ) 49 | created_at = models.DateTimeField( 50 | auto_now_add=True, 51 | verbose_name="생성 일시", 52 | ) 53 | updated_at = models.DateTimeField( 54 | auto_now=True, 55 | verbose_name="수정 일시", 56 | ) 57 | 58 | class Meta: 59 | db_table = "notice" 60 | verbose_name = "공지사항" 61 | verbose_name_plural = "공지사항" 62 | 63 | 64 | class Event(models.Model): 65 | """이벤트""" 66 | 67 | uuid = models.UUIDField( 68 | primary_key=True, 69 | default=uuid7, 70 | editable=False, 71 | verbose_name="UUID", 72 | ) 73 | author = models.ForeignKey( 74 | "user.User", 75 | on_delete=models.CASCADE, 76 | verbose_name="작성자", 77 | help_text="이벤트 작성자", 78 | ) 79 | title = models.CharField( 80 | max_length=255, 81 | verbose_name="제목", 82 | help_text="제목", 83 | ) 84 | content = models.TextField( 85 | verbose_name="내용", 86 | help_text="내용", 87 | ) 88 | # 표시 기간 89 | start_at = models.DateTimeField( 90 | verbose_name="시작 일시", 91 | help_text="이벤트 시작 일시", 92 | ) 93 | end_at = models.DateTimeField( 94 | null=True, 95 | blank=True, 96 | verbose_name="종료 일시", 97 | help_text="이벤트 종료 일시", 98 | ) 99 | # 이벤트 기간 100 | event_start_at = models.DateTimeField( 101 | verbose_name="이벤트 시작 일시", 102 | help_text="이벤트 시작 일시", 103 | ) 104 | event_end_at = models.DateTimeField( 105 | null=True, 106 | blank=True, 107 | verbose_name="이벤트 종료 일시", 108 | help_text="이벤트 종료 일시", 109 | ) 110 | is_published = models.BooleanField( 111 | default=False, 112 | verbose_name="발행 여부", 113 | ) 114 | published_at = models.DateTimeField( 115 | verbose_name="발행 일시", 116 | help_text="이벤트 발행 일시", 117 | db_index=True, 118 | ) 119 | created_at = models.DateTimeField( 120 | auto_now_add=True, 121 | verbose_name="생성 일시", 122 | ) 123 | updated_at = models.DateTimeField( 124 | auto_now=True, 125 | verbose_name="수정 일시", 126 | ) 127 | 128 | class Meta: 129 | db_table = "event" 130 | verbose_name = "이벤트" 131 | verbose_name_plural = "이벤트" 132 | 133 | 134 | class FaqCategory(models.Model): 135 | """FAQ 카테고리""" 136 | 137 | uuid = models.UUIDField( 138 | primary_key=True, 139 | default=uuid7, 140 | editable=False, 141 | verbose_name="UUID", 142 | ) 143 | name = models.CharField( 144 | max_length=255, 145 | verbose_name="카테고리 이름", 146 | help_text="카테고리 이름", 147 | ) 148 | created_at = models.DateTimeField( 149 | auto_now_add=True, 150 | verbose_name="생성 일시", 151 | ) 152 | updated_at = models.DateTimeField( 153 | auto_now=True, 154 | verbose_name="수정 일시", 155 | ) 156 | 157 | class Meta: 158 | db_table = "faq_category" 159 | verbose_name = "FAQ 카테고리" 160 | verbose_name_plural = "FAQ 카테고리" 161 | 162 | 163 | class Faq(models.Model): 164 | """FAQ""" 165 | 166 | uuid = models.UUIDField( 167 | primary_key=True, 168 | default=uuid7, 169 | editable=False, 170 | verbose_name="UUID", 171 | ) 172 | author = models.ForeignKey( 173 | "user.User", 174 | on_delete=models.CASCADE, 175 | verbose_name="작성자", 176 | help_text="FAQ 작성자", 177 | ) 178 | category = models.ForeignKey( 179 | FaqCategory, 180 | on_delete=models.CASCADE, 181 | verbose_name="카테고리", 182 | help_text="FAQ 카테고리", 183 | ) 184 | title = models.CharField( 185 | max_length=255, 186 | verbose_name="제목", 187 | help_text="제목", 188 | ) 189 | content = models.TextField( 190 | verbose_name="내용", 191 | help_text="내용", 192 | ) 193 | is_published = models.BooleanField( 194 | default=False, 195 | verbose_name="발행 여부", 196 | ) 197 | published_at = models.DateTimeField( 198 | verbose_name="발행 일시", 199 | help_text="FAQ 발행 일시", 200 | db_index=True, 201 | ) 202 | created_at = models.DateTimeField( 203 | auto_now_add=True, 204 | verbose_name="생성 일시", 205 | ) 206 | updated_at = models.DateTimeField( 207 | auto_now=True, 208 | verbose_name="수정 일시", 209 | ) 210 | 211 | class Meta: 212 | db_table = "faq" 213 | verbose_name = "FAQ" 214 | verbose_name_plural = "FAQ" 215 | --------------------------------------------------------------------------------