Compare commits
10 Commits
85e285e737
...
f4788f66bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4788f66bc | ||
|
|
32c11c40bb | ||
|
|
6370f50aca | ||
|
|
caa52fbd0b | ||
|
|
ee7e98235a | ||
|
|
d9ac5578c9 | ||
|
|
a4fa5d152b | ||
|
|
41c1aa7013 | ||
|
|
0d129d14fd | ||
|
|
196498d879 |
@@ -22,6 +22,10 @@ from users.models import User
|
||||
|
||||
from projects.models import Project, ProjectTeamList
|
||||
|
||||
|
||||
from datetime import timedelta
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
|
||||
# from .schemas import send_sms_post_schema # Swagger나 drf-spectacular 등에 사용되는 데코레이터
|
||||
|
||||
INVITE_CHOICE_USE_TYPE ={
|
||||
@@ -29,6 +33,8 @@ INVITE_CHOICE_USE_TYPE ={
|
||||
'h': InviteCodeUseType.HACKATHON
|
||||
}
|
||||
|
||||
PASSWORD_RESET_TOKEN_TTL_MINUTES = 5
|
||||
|
||||
class CertificateService:
|
||||
@staticmethod
|
||||
def send(code, identifier):
|
||||
@@ -131,3 +137,15 @@ class ProjectInviteService(InviteService):
|
||||
return ProjectTeamList.objects.create(user=invitee, project=work)
|
||||
|
||||
|
||||
class PasswordResetTokenService:
|
||||
@staticmethod
|
||||
def issue_temp_access_token(*, user_id: str, identifier: str, use_type: str) -> str:
|
||||
token = AccessToken()
|
||||
token.set_exp(lifetime=timedelta(minutes=PASSWORD_RESET_TOKEN_TTL_MINUTES))
|
||||
|
||||
token["purpose"] = "password_reset"
|
||||
token["user_id"] = str(user_id)
|
||||
token["identifier"] = identifier
|
||||
token["use_type"] = use_type
|
||||
|
||||
return str(token)
|
||||
@@ -56,18 +56,43 @@ class CertificationAPIView(APIView):
|
||||
@transaction.atomic
|
||||
def patch(self, request):
|
||||
use_type = request.query_params.get("type")
|
||||
purpose = request.query_params.get("purpose")
|
||||
|
||||
if use_type not in CERTIFICATE_SERVICE_USE_TYPE:
|
||||
return Response({"message": "Not defined use_type"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serv = CERTIFICATE_SERVICE_USE_TYPE[use_type]
|
||||
code = request.data.get('code', None)
|
||||
if not code:
|
||||
return Response({"message": "no code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
serializer = CertificateCodeSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
if serv.check_code(use_type, code, serializer.validated_data['identifier']):
|
||||
return Response({"message": "certificated successfully"}, status=status.HTTP_200_OK)
|
||||
return Response({"message": "wrong code, please retry"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
identifier = serializer.validated_data["identifier"]
|
||||
|
||||
if not serv.check_code(use_type, code, identifier):
|
||||
return Response({"message": "wrong code or already used code, please retry send code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if purpose == "password_reset":
|
||||
user = User.objects.filter(phone=identifier).first()
|
||||
if not user:
|
||||
return Response({"message": "user not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
temp_access_token = PasswordResetTokenService.issue_temp_access_token(
|
||||
user_id=user.id,
|
||||
identifier=identifier,
|
||||
use_type=use_type,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"message": "certificated successfully", "temp_access_token": temp_access_token},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response({"message": "certificated successfully"}, status=status.HTTP_200_OK)
|
||||
|
||||
class InviteByLinkAPIView(APIView):
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ SOLAPI_API_KEY = env('SOLAPI_API_KEY')
|
||||
SOLAPI_API_SECRET = env('SOLAPI_API_SECRET')
|
||||
FROM_PHONE_NUMBER = env('FROM_PHONE_NUMBER')
|
||||
|
||||
MAIL_SIGNUP_ENABLED = os.getenv("MAIL_SIGNUP_ENABLED", "0") == "1"
|
||||
|
||||
DEBUG = env.bool('DEBUG')
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
@@ -67,6 +69,7 @@ INSTALLED_APPS = [
|
||||
'notifications',
|
||||
'nocodetools',
|
||||
'homes',
|
||||
'mail'
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -15,5 +15,10 @@ urlpatterns = [
|
||||
path('api/home/', include('homes.urls')),
|
||||
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
if getattr(settings, "MAIL_SIGNUP_ENABLED", False):
|
||||
urlpatterns += [
|
||||
path('api/mail/', include('mail.urls')),
|
||||
]
|
||||
|
||||
# if settings.DEBUG:
|
||||
# urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
0
mail/__init__.py
Normal file
0
mail/__init__.py
Normal file
3
mail/admin.py
Normal file
3
mail/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
mail/apps.py
Normal file
6
mail/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MailConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'mail'
|
||||
3
mail/models.py
Normal file
3
mail/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
114
mail/templates/mail/signup.html
Normal file
114
mail/templates/mail/signup.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>메일 계정 신청</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 720px;
|
||||
margin: 40px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 6px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 16px;
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #eef;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: #fee;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>메일 계정 신청</h1>
|
||||
<div class="card">
|
||||
<p><small>HTTPS에서만 사용하세요. 생성 즉시 반영됩니다.</small></p>
|
||||
|
||||
<label>아이디(메일주소 앞부분)</label>
|
||||
<input id="local" placeholder="예: nkey" autocomplete="username" />
|
||||
|
||||
<label>비밀번호</label>
|
||||
<input id="password" type="password" placeholder="5자 이상" autocomplete="new-password" />
|
||||
|
||||
<label>초대코드</label>
|
||||
<input id="invite" placeholder="관리자에게 받은 코드" />
|
||||
|
||||
<button id="btn">제출</button>
|
||||
|
||||
<div id="msg"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const msg = (type, text) => {
|
||||
const el = document.getElementById('msg');
|
||||
el.className = type;
|
||||
el.textContent = text;
|
||||
};
|
||||
|
||||
document.getElementById('btn').addEventListener('click', async () => {
|
||||
const local = document.getElementById('local').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const invite = document.getElementById('invite').value.trim();
|
||||
|
||||
msg('', '');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/mail/request/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ local, password, invite })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || '요청 실패');
|
||||
|
||||
msg('ok', data.message);
|
||||
} catch (e) {
|
||||
msg('err', e.message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
3
mail/tests.py
Normal file
3
mail/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
8
mail/urls.py
Normal file
8
mail/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
from .views import signup_page, request_account, admin_list
|
||||
|
||||
urlpatterns = [
|
||||
path("signup/", signup_page, name="mail-signup-page"),
|
||||
path("request/", request_account, name="mail-signup-request"),
|
||||
path("list/", admin_list, name="mail-signup-list"),
|
||||
]
|
||||
183
mail/views.py
Normal file
183
mail/views.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import render
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import AllowAny, IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import NotFound, APIException
|
||||
|
||||
try:
|
||||
import docker
|
||||
except ImportError:
|
||||
docker = None
|
||||
|
||||
LOCAL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$")
|
||||
|
||||
ANSI_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
||||
EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Feature toggle
|
||||
# ----------------------------
|
||||
def _require_enabled():
|
||||
if not getattr(settings, "MAIL_SIGNUP_ENABLED", False):
|
||||
raise NotFound("Not Found") # 숨김(404)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Docker-mailserver exec helpers
|
||||
# ----------------------------
|
||||
def _docker_client():
|
||||
"""
|
||||
Uses docker socket mounted into this container:
|
||||
/var/run/docker.sock
|
||||
"""
|
||||
if docker is None:
|
||||
raise APIException("Python package 'docker' is not installed in this server image.")
|
||||
|
||||
sock = getattr(settings, "MAIL_DOCKER_SOCK", None) or os.getenv("MAIL_DOCKER_SOCK", "unix://var/run/docker.sock")
|
||||
try:
|
||||
client = docker.DockerClient(base_url=sock)
|
||||
# ping to ensure socket is usable
|
||||
client.ping()
|
||||
return client
|
||||
except Exception as e:
|
||||
raise APIException(f"Docker socket is not available. Check /var/run/docker.sock mount. ({e})")
|
||||
|
||||
|
||||
def _dms_container_name() -> str:
|
||||
return getattr(settings, "MAIL_DMS_CONTAINER", None) or os.getenv("MAIL_DMS_CONTAINER", "dms-mailserver")
|
||||
|
||||
|
||||
def _dms_exec(setup_args):
|
||||
"""
|
||||
Runs docker-mailserver's internal `setup` command:
|
||||
docker exec dms-mailserver setup <args...>
|
||||
|
||||
Example:
|
||||
_dms_exec(["email", "list"])
|
||||
_dms_exec(["email", "add", "user@domain", "password"])
|
||||
"""
|
||||
client = _docker_client()
|
||||
name = _dms_container_name()
|
||||
|
||||
try:
|
||||
c = client.containers.get(name)
|
||||
except Exception as e:
|
||||
raise APIException(f"Cannot find mailserver container '{name}'. ({e})")
|
||||
|
||||
# Try to execute: setup <args...>
|
||||
try:
|
||||
res = c.exec_run(["setup", *setup_args], demux=True)
|
||||
out, err = res.output
|
||||
stdout = (out or b"").decode("utf-8", errors="ignore").strip()
|
||||
stderr = (err or b"").decode("utf-8", errors="ignore").strip()
|
||||
|
||||
if res.exit_code != 0:
|
||||
# show meaningful error to API consumer
|
||||
raise ValueError(stderr or stdout or "docker-mailserver setup failed")
|
||||
|
||||
return stdout
|
||||
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise APIException(f"Failed to exec into '{name}'. ({e})")
|
||||
|
||||
|
||||
def _wait_until_exists(address: str, tries: int = 3, delay: float = 0.3) -> bool:
|
||||
addr = address.strip().lower()
|
||||
for _ in range(tries):
|
||||
if addr in _email_list():
|
||||
return True
|
||||
time.sleep(delay)
|
||||
return False
|
||||
|
||||
def _email_list():
|
||||
raw = _dms_exec(["email", "list"])
|
||||
raw = ANSI_RE.sub("", raw) # 색상코드 제거
|
||||
# 어떤 형식이 와도 이메일만 뽑아냄
|
||||
emails = set(m.group(0).strip().lower() for m in EMAIL_RE.finditer(raw))
|
||||
return sorted(emails)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Views
|
||||
# ----------------------------
|
||||
def signup_page(request):
|
||||
_require_enabled()
|
||||
return render(request, "mail/signup.html")
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
def request_account(request):
|
||||
_require_enabled()
|
||||
|
||||
invite_expected = getattr(settings, "MAIL_INVITE_CODE", None) or os.getenv("MAIL_INVITE_CODE", "")
|
||||
dms_domain = getattr(settings, "MAIL_DMS_DOMAIN", None) or os.getenv("MAIL_DMS_DOMAIN", "")
|
||||
|
||||
local = (request.data.get("local") or "").strip().lower()
|
||||
password = request.data.get("password") or ""
|
||||
invite = (request.data.get("invite") or "").strip()
|
||||
|
||||
if not invite_expected or invite != invite_expected:
|
||||
return Response({"detail": "초대코드가 올바르지 않습니다."}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if not dms_domain:
|
||||
return Response(
|
||||
{"detail": "서버 설정(MAIL_DMS_DOMAIN)이 비어있습니다."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
if not LOCAL_RE.match(local):
|
||||
return Response(
|
||||
{"detail": "아이디 형식이 올바르지 않습니다. (영문소문자/숫자/._- 허용)"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if len(password) < 5:
|
||||
return Response(
|
||||
{"detail": "비밀번호는 5자 이상으로 설정하세요."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
address = f"{local}@{dms_domain}"
|
||||
|
||||
try:
|
||||
emails = _email_list()
|
||||
if address in emails:
|
||||
return Response({"message": f"ℹ️ {address} 는 이미 존재합니다. (email list에서 확인됨)"})
|
||||
|
||||
# Create account inside dms-mailserver
|
||||
_dms_exec(["email", "add", address, password])
|
||||
|
||||
if _wait_until_exists(address):
|
||||
return Response({"message": f"✅ {address} 계정이 잘 만들어졌습니다! (email list에서 확인됨)"})
|
||||
|
||||
return Response({"detail": "계정 생성 후 list에서 확인이 안 됩니다. 다시 제출 버튼을 눌러서 확인해보시거나 관리자에게 문의해주세요."}, status=500)
|
||||
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
if "already exists" in msg.lower():
|
||||
return Response({"message": f"ℹ️ {address} 는 이미 존재합니다."})
|
||||
return Response({"detail": msg}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
return Response({"detail": f"서버 오류: {e}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAdminUser])
|
||||
def admin_list(request):
|
||||
_require_enabled()
|
||||
|
||||
try:
|
||||
emails = _email_list()
|
||||
return Response({"count": len(emails), "emails": emails})
|
||||
except Exception as e:
|
||||
return Response({"detail": f"서버 오류: {e}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
@@ -1,29 +1,85 @@
|
||||
from django.db import models
|
||||
# models.py
|
||||
from django.utils import timezone
|
||||
|
||||
import mongoengine as me
|
||||
|
||||
|
||||
class Element(me.EmbeddedDocument):
|
||||
element_id = me.IntField()
|
||||
element_type = me.StringField()
|
||||
content = me.StringField()
|
||||
css = me.DictField()
|
||||
class NodeType(me.EmbeddedDocument):
|
||||
"""
|
||||
"type": { "resolvedName": "CraftFrame" } 형태
|
||||
"""
|
||||
resolvedName = me.StringField(required=True)
|
||||
|
||||
|
||||
class CraftNode(me.EmbeddedDocument):
|
||||
"""
|
||||
CraftJS node 1개(값) 스키마
|
||||
"""
|
||||
type = me.EmbeddedDocumentField(NodeType, required=True)
|
||||
isCanvas = me.BooleanField(default=False)
|
||||
|
||||
# props/custom/linkedNodes는 구조가 유동적이므로 dict로 저장
|
||||
props = me.DictField(default=dict)
|
||||
displayName = me.StringField()
|
||||
custom = me.DictField(default=dict)
|
||||
|
||||
hidden = me.BooleanField(default=False)
|
||||
|
||||
# 자식 노드 id 리스트
|
||||
nodes = me.ListField(me.StringField(), default=list)
|
||||
|
||||
# linkedNodes: { "ROOT-background": "mBIaZ3L5VU" } 같은 맵
|
||||
linkedNodes = me.DictField(default=dict)
|
||||
|
||||
# 일부 노드에 존재 ("parent": "ROOT")
|
||||
parent = me.StringField()
|
||||
|
||||
|
||||
class Page(me.EmbeddedDocument):
|
||||
cut = me.IntField()
|
||||
elements = me.ListField(me.EmbeddedDocumentField(Element))
|
||||
"""
|
||||
pages[*] 구조:
|
||||
{
|
||||
"page_id": 1,
|
||||
"title": "...",
|
||||
"data": { "ROOT": {...}, "mBIaZ3L5VU": {...}, ... }
|
||||
}
|
||||
"""
|
||||
page_id = me.IntField(required=True)
|
||||
title = me.StringField(required=True)
|
||||
|
||||
# data는 nodeId -> CraftNode 맵
|
||||
data = me.MapField(me.EmbeddedDocumentField(CraftNode), default=dict)
|
||||
|
||||
|
||||
class Code(me.Document):
|
||||
pages = me.ListField(me.EmbeddedDocumentField(Page))
|
||||
"""
|
||||
최상위 구조:
|
||||
{
|
||||
"keyword": [...],
|
||||
"description": "...",
|
||||
"pages": [...]
|
||||
}
|
||||
+ (기존에 쓰던 object_type/object_id 유지)
|
||||
"""
|
||||
pages = me.EmbeddedDocumentListField(Page, default=list)
|
||||
|
||||
keyword = me.ListField(me.StringField(max_length=50), default=list)
|
||||
description = me.StringField(default='', blank=True)
|
||||
object_type = me.StringField(choices=("portfolio", "project"))
|
||||
object_id = me.StringField()
|
||||
description = me.StringField(default="")
|
||||
|
||||
object_type = me.StringField(choices=("portfolio", "project"), required=True)
|
||||
object_id = me.StringField(required=True)
|
||||
|
||||
created_at = me.DateTimeField(default=timezone.now)
|
||||
updated_at = me.DateTimeField(default=timezone.now)
|
||||
|
||||
meta = {
|
||||
"collection": "codes",
|
||||
"indexes": [
|
||||
("object_type", "object_id"),
|
||||
"created_at",
|
||||
"updated_at",
|
||||
],
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.updated_at = timezone.now()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@@ -1,30 +1,67 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Code, Page, Element
|
||||
|
||||
# from datetime import datetime, timezone
|
||||
|
||||
from django.utils import timezone
|
||||
# from zoneinfo import ZoneInfo
|
||||
|
||||
# KST = ZoneInfo("Asia/Seoul")
|
||||
from .models import Code, Page, CraftNode, NodeType # (네 models.py에 맞춰 import)
|
||||
|
||||
class ElementSerializer(serializers.Serializer):
|
||||
element_id = serializers.CharField(required=False)
|
||||
element_type = serializers.CharField()
|
||||
content = serializers.CharField(allow_blank=True)
|
||||
css = serializers.DictField()
|
||||
|
||||
# -------------------------
|
||||
# Node (CraftJS)
|
||||
# -------------------------
|
||||
class NodeTypeSerializer(serializers.Serializer):
|
||||
resolvedName = serializers.CharField()
|
||||
|
||||
|
||||
class CraftNodeSerializer(serializers.Serializer):
|
||||
type = NodeTypeSerializer()
|
||||
isCanvas = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
props = serializers.DictField(required=False, default=dict)
|
||||
displayName = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||||
custom = serializers.DictField(required=False, default=dict)
|
||||
|
||||
hidden = serializers.BooleanField(required=False, default=False)
|
||||
nodes = serializers.ListField(child=serializers.CharField(), required=False, default=list)
|
||||
linkedNodes = serializers.DictField(required=False, default=dict)
|
||||
|
||||
parent = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Page
|
||||
# -------------------------
|
||||
class PageSerializer(serializers.Serializer):
|
||||
cut = serializers.IntegerField()
|
||||
elements = ElementSerializer(many=True)
|
||||
page_id = serializers.IntegerField()
|
||||
title = serializers.CharField()
|
||||
|
||||
# data: { "ROOT": {...}, "mBIaZ3L5VU": {...}, ... }
|
||||
# -> key는 문자열(node id), value는 CraftNode
|
||||
data = serializers.DictField(
|
||||
child=CraftNodeSerializer(),
|
||||
required=False,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Code
|
||||
# -------------------------
|
||||
class CodeSerializer(serializers.Serializer):
|
||||
id = serializers.SerializerMethodField()
|
||||
pages = PageSerializer(many=True, required=False)
|
||||
keyword = serializers.ListField(child=serializers.CharField(), required=False, allow_empty=True)
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
created_at = serializers.SerializerMethodField()
|
||||
updated_at = serializers.SerializerMethodField()
|
||||
keyword = serializers.ListField(
|
||||
child=serializers.CharField(max_length=50),
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
default=list,
|
||||
)
|
||||
description = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
|
||||
pages = PageSerializer(many=True, required=False, default=list)
|
||||
|
||||
created_at = serializers.SerializerMethodField()
|
||||
updated_at = serializers.SerializerMethodField()
|
||||
|
||||
def get_id(self, obj):
|
||||
return str(obj.id)
|
||||
|
||||
def get_created_at(self, obj):
|
||||
return timezone.localtime(obj.created_at).isoformat(timespec="seconds")
|
||||
@@ -32,46 +69,77 @@ class CodeSerializer(serializers.Serializer):
|
||||
def get_updated_at(self, obj):
|
||||
return timezone.localtime(obj.updated_at).isoformat(timespec="seconds")
|
||||
|
||||
def get_id(self, obj):
|
||||
return str(obj.id)
|
||||
# -------------------------
|
||||
# helpers: dict -> EmbeddedDocument
|
||||
# -------------------------
|
||||
def _to_node_type(self, data: dict) -> NodeType:
|
||||
return NodeType(resolvedName=data.get("resolvedName", ""))
|
||||
|
||||
def _to_craft_node(self, data: dict) -> CraftNode:
|
||||
# type 필수
|
||||
node_type = self._to_node_type(data["type"])
|
||||
|
||||
return CraftNode(
|
||||
type=node_type,
|
||||
isCanvas=data.get("isCanvas", False),
|
||||
props=data.get("props", {}) or {},
|
||||
displayName=data.get("displayName"),
|
||||
custom=data.get("custom", {}) or {},
|
||||
hidden=data.get("hidden", False),
|
||||
nodes=data.get("nodes", []) or [],
|
||||
linkedNodes=data.get("linkedNodes", {}) or {},
|
||||
parent=data.get("parent"),
|
||||
)
|
||||
|
||||
def _to_page(self, page: dict) -> Page:
|
||||
raw_map = page.get("data", {}) or {}
|
||||
node_map = {node_id: self._to_craft_node(node_dict) for node_id, node_dict in raw_map.items()}
|
||||
|
||||
return Page(
|
||||
page_id=page["page_id"],
|
||||
title=page["title"],
|
||||
data=node_map,
|
||||
)
|
||||
|
||||
# -------------------------
|
||||
# create / update
|
||||
# -------------------------
|
||||
def create(self, validated_data):
|
||||
request = self.context['request']
|
||||
validated_data['object_type'] = request.query_params.get('type')
|
||||
validated_data['object_id'] = request.query_params.get('id')
|
||||
pages_data = validated_data.pop('pages')
|
||||
pages = [
|
||||
Page(
|
||||
cut=page['cut'],
|
||||
elements=[Element(**el) for el in page['elements']]
|
||||
) for page in pages_data
|
||||
]
|
||||
request = self.context["request"]
|
||||
|
||||
validated_data["object_type"] = request.query_params.get("type")
|
||||
validated_data["object_id"] = request.query_params.get("id")
|
||||
|
||||
pages_data = validated_data.pop("pages", [])
|
||||
pages = [self._to_page(p) for p in pages_data]
|
||||
|
||||
code = Code(pages=pages, **validated_data)
|
||||
code.save()
|
||||
return code
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
update_pages_data = validated_data.get('pages', [])
|
||||
existing = {p.cut: p for p in instance.pages}
|
||||
|
||||
for page in update_pages_data:
|
||||
cut = page.get('cut')
|
||||
if cut and cut in existing:
|
||||
page_obj = existing[cut]
|
||||
page_obj.elements = [Element(**el) for el in page["elements"]]
|
||||
|
||||
else:
|
||||
instance.pages.append(
|
||||
Page(
|
||||
cut=cut,
|
||||
elements=[Element(**el) for el in page["elements"]]
|
||||
)
|
||||
)
|
||||
# pages는 page_id 기준으로 upsert
|
||||
update_pages_data = validated_data.get("pages", [])
|
||||
existing = {p.page_id: p for p in instance.pages}
|
||||
|
||||
if 'keyword' in validated_data:
|
||||
instance.keyword = validated_data['keyword']
|
||||
if 'description' in validated_data:
|
||||
instance.description = validated_data['description']
|
||||
for page in update_pages_data:
|
||||
pid = page.get("page_id")
|
||||
if pid is None:
|
||||
continue
|
||||
|
||||
new_page_obj = self._to_page(page)
|
||||
|
||||
if pid in existing:
|
||||
page_obj = existing[pid]
|
||||
page_obj.title = new_page_obj.title
|
||||
page_obj.data = new_page_obj.data
|
||||
else:
|
||||
instance.pages.append(new_page_obj)
|
||||
|
||||
if "keyword" in validated_data:
|
||||
instance.keyword = validated_data["keyword"]
|
||||
if "description" in validated_data:
|
||||
instance.description = validated_data["description"]
|
||||
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
@@ -5,7 +5,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from .models import Code, Page, Element
|
||||
from .models import Code
|
||||
from .serializers import CodeSerializer
|
||||
from .permissions import IsOwnerOrMemberInCreateAndUpdateAndDelete, IsNotPublished
|
||||
from .services import NocodetoolObjectMapService, NocodetoolHitService
|
||||
@@ -24,25 +24,25 @@ class NoCodeToolAPIView(APIView):
|
||||
def get(self, request):
|
||||
related_type = request.query_params.get("type")
|
||||
related_id = request.query_params.get("id")
|
||||
code_id = None
|
||||
if obj := NocodetoolObjectMapService.mapping_model_instance(related_type, related_id):
|
||||
if obj.is_published:
|
||||
NocodetoolHitService.hit_once(obj, request)
|
||||
code_id = ObjectId(obj.code_id)
|
||||
|
||||
if not code_id:
|
||||
obj = NocodetoolObjectMapService.mapping_model_instance(related_type, related_id)
|
||||
if not obj or not obj.code_id:
|
||||
return Response({"message": "Not validated type or no object"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if obj.is_published:
|
||||
NocodetoolHitService.hit_once(obj, request)
|
||||
|
||||
code = Code.objects.filter(id=ObjectId(obj.code_id)).first()
|
||||
if not code:
|
||||
return Response({"message": "No code object"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
obj_serializer = NocodetoolObjectMapService.mapping_model_serializer(related_type)
|
||||
|
||||
code = Code.objects.filter(id=code_id).first()
|
||||
if not code:
|
||||
return Response({"message": "No code object"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({
|
||||
"obj_info": obj_serializer(obj).data,
|
||||
"codes": CodeSerializer(code).data
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
"obj_info": obj_serializer(obj).data,
|
||||
"codes": CodeSerializer(code).data
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
related_type = request.query_params.get("type")
|
||||
@@ -50,32 +50,33 @@ class NoCodeToolAPIView(APIView):
|
||||
obj = NocodetoolObjectMapService.mapping_model_instance(related_type, related_id)
|
||||
if not obj:
|
||||
return Response({"message": "No object"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
if not obj.now_worker:
|
||||
return Response({"message": "start edit first"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if obj.now_worker != request.user.nickname:
|
||||
return Response({"message": f"{obj.now_worker} is working now"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
data = request.data.copy()
|
||||
thumbnail_file = data.pop("thumbnail", None)
|
||||
|
||||
obj_serializer = NocodetoolObjectMapService.mapping_model_serializer(related_type)
|
||||
|
||||
serializer = CodeSerializer(data=request.data, context={'request': request})
|
||||
serializer = CodeSerializer(data=data, context={'request': request})
|
||||
if serializer.is_valid():
|
||||
code = serializer.save()
|
||||
obj.code_id = str(code.id)
|
||||
obj.save()
|
||||
obj.save(update_fields=["code_id"])
|
||||
|
||||
if thumbnail_file:
|
||||
obj.thumbnail = thumbnail_file
|
||||
obj.save(update_fields=["thumbnail"])
|
||||
|
||||
|
||||
return Response({
|
||||
"obj_info": obj_serializer(obj).data,
|
||||
"codes": CodeSerializer(code).data
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@transaction.atomic
|
||||
@@ -85,21 +86,25 @@ class NoCodeToolAPIView(APIView):
|
||||
obj = NocodetoolObjectMapService.mapping_model_instance(related_type, related_id)
|
||||
if not obj:
|
||||
return Response({"message": "No object"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
if not obj.now_worker:
|
||||
return Response({"message": "start edit first"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if obj.now_worker != request.user.nickname:
|
||||
return Response({"message": f"{obj.now_worker} is working now"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
if not obj.code_id:
|
||||
return Response({'message': 'No code object'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
code = Code.objects.filter(id=ObjectId(obj.code_id)).first()
|
||||
if not code:
|
||||
return Response({'message': 'No code object'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
data = request.data.copy()
|
||||
thumbnail_file = data.pop("thumbnail", None)
|
||||
|
||||
obj_serializer = NocodetoolObjectMapService.mapping_model_serializer(related_type)
|
||||
|
||||
serializer = CodeSerializer(code, data=data, partial=True, context={'request': request})
|
||||
if serializer.is_valid():
|
||||
updated_code = serializer.save()
|
||||
@@ -112,8 +117,9 @@ class NoCodeToolAPIView(APIView):
|
||||
"obj_info": obj_serializer(obj).data,
|
||||
"codes": CodeSerializer(updated_code).data
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def delete(self, request):
|
||||
related_type = request.query_params.get("type")
|
||||
@@ -121,20 +127,25 @@ class NoCodeToolAPIView(APIView):
|
||||
obj = NocodetoolObjectMapService.mapping_model_instance(related_type, related_id)
|
||||
if not obj:
|
||||
return Response({"message": "No object"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
if not obj.now_worker:
|
||||
return Response({"message": "start edit first"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if obj.now_worker != request.user.nickname:
|
||||
return Response({"message": f"{obj.now_worker} is working now"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
if not obj.code_id:
|
||||
return Response({'message': 'No code object'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
code = Code.objects.filter(id=ObjectId(obj.code_id)).first()
|
||||
if not code:
|
||||
return Response({'message': 'No code object'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
code.delete()
|
||||
obj.code_id = None
|
||||
obj.thumbnail = None
|
||||
obj.save()
|
||||
obj.save(update_fields=["code_id", "thumbnail"])
|
||||
|
||||
return Response({"message": "delete code success"}, status=status.HTTP_200_OK)
|
||||
|
||||
class NocodeToolWorkingAPIView(APIView):
|
||||
|
||||
@@ -54,12 +54,15 @@ class PortfolioSetRepresentAPIView(APIView):
|
||||
portfolio = get_object_or_404(Portfolio, id=pk)
|
||||
if user != portfolio.owner:
|
||||
return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN)
|
||||
if before_represent := Portfolio.objects.filter(owner=user, is_represent=True).first():
|
||||
before_represent.is_represent = False
|
||||
before_represent.save()
|
||||
portfolio.is_represent = True
|
||||
|
||||
is_representing = request.data.get("representing", False)
|
||||
if is_representing:
|
||||
if before_represent := Portfolio.objects.filter(owner=user, is_represent=True).first():
|
||||
before_represent.is_represent = False
|
||||
before_represent.save()
|
||||
portfolio.is_represent = is_representing
|
||||
portfolio.save()
|
||||
return Response({"message": "change represent success"}, status=status.HTTP_200_OK)
|
||||
return Response({"is_represented": is_representing}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class PortfolioSetPublishAPIView(APIView):
|
||||
@@ -69,9 +72,10 @@ class PortfolioSetPublishAPIView(APIView):
|
||||
portfolio = get_object_or_404(Portfolio, id=pk)
|
||||
if user != portfolio.owner:
|
||||
return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN)
|
||||
portfolio.is_published = True
|
||||
is_publishing = request.data.get("publishing", False)
|
||||
portfolio.is_published = is_publishing
|
||||
portfolio.save()
|
||||
return Response({"message": "publish success"}, status=status.HTTP_200_OK)
|
||||
return Response({"is_published": is_publishing}, status=status.HTTP_200_OK)
|
||||
|
||||
class PortfolioChangeState(APIView):
|
||||
@transaction.atomic
|
||||
|
||||
@@ -89,12 +89,15 @@ class ProjectSetRepresentAPIView(APIView):
|
||||
project = get_object_or_404(Project, id=pk)
|
||||
if user != project.owner:
|
||||
return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN)
|
||||
if before_represent := Project.objects.filter(owner=user, is_represent=True).first():
|
||||
before_represent.is_represent = False
|
||||
before_represent.save()
|
||||
project.is_represent = True
|
||||
|
||||
is_representing = request.data.get("representing", False)
|
||||
if is_representing:
|
||||
if before_represent := Project.objects.filter(owner=user, is_represent=True).first():
|
||||
before_represent.is_represent = False
|
||||
before_represent.save()
|
||||
project.is_represent = is_representing
|
||||
project.save()
|
||||
return Response({"message": "change represent success"}, status=status.HTTP_200_OK)
|
||||
return Response({"is_represented": is_representing}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ProjectSetPublishAPIView(APIView):
|
||||
@@ -104,9 +107,10 @@ class ProjectSetPublishAPIView(APIView):
|
||||
project = get_object_or_404(Project, id=pk)
|
||||
if user != project.owner:
|
||||
return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN)
|
||||
project.is_published = True
|
||||
is_publishing = request.data.get("publishing", False)
|
||||
project.is_published = is_publishing
|
||||
project.save()
|
||||
return Response({"message": "publish success"}, status=status.HTTP_200_OK)
|
||||
return Response({"is_published": is_publishing}, status=status.HTTP_200_OK)
|
||||
|
||||
class ProjectChangeState(APIView):
|
||||
@transaction.atomic
|
||||
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
@@ -8,4 +8,14 @@ class IsGithubLinked(BasePermission):
|
||||
if token_obj := getattr(user, 'github_token', None):
|
||||
if token := getattr(token_obj, 'access_token', None):
|
||||
return True
|
||||
return False
|
||||
return False
|
||||
|
||||
class IsPasswordResetToken(BasePermission):
|
||||
message = "Password reset token required."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
token = getattr(request, "auth", None)
|
||||
if not token:
|
||||
return False
|
||||
|
||||
return token.get("purpose") == "password_reset"
|
||||
@@ -24,4 +24,5 @@ urlpatterns = [
|
||||
path('mypage/my-info/', MyPageMemberInfoAPIView.as_view()),
|
||||
path("github/", include(router.urls)),
|
||||
path("portfolio/", UserInfoAPIView.as_view()),
|
||||
path("reset-password/", PasswordResetAPIView.as_view()),
|
||||
]
|
||||
@@ -40,6 +40,7 @@ from github import Github, GithubException
|
||||
|
||||
CACHE_TIMEOUT = 60 * 60
|
||||
PAGE_SIZE = 20
|
||||
PASSWORD_RESET_USED_JTI_TTL = 60 * 10
|
||||
|
||||
class RefreshAPIView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
@@ -388,6 +389,48 @@ class LoginAPIView(APIView):
|
||||
else: # id, 비번 둘 중 하나가 틀렸을 때
|
||||
return Response({"message": "아이디 혹은 비밀번호가 맞지 않습니다."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class PasswordResetAPIView(APIView):
|
||||
permission_classes = [IsAuthenticated, IsPasswordResetToken]
|
||||
|
||||
# 비밀번호 재설정
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
new_password = request.data.get("new_password")
|
||||
|
||||
if not new_password:
|
||||
return Response({"message": "no new_password", "is_new_password": None})
|
||||
|
||||
token = request.auth
|
||||
jti = token.get("jti")
|
||||
user_id = token.get("user_id")
|
||||
|
||||
if not jti or not user_id:
|
||||
return Response({"message": "invalid token"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 1회성 방지
|
||||
used_key = f"pwreset-used:{jti}"
|
||||
if cache.get(used_key):
|
||||
return Response({"message": "token already used"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
cache.set(used_key, True, timeout=PASSWORD_RESET_USED_JTI_TTL)
|
||||
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save(update_fields=["password"])
|
||||
|
||||
# 쿠키에 있는 리프레시 토큰 무효화
|
||||
try:
|
||||
refresh = request.COOKIES.get('refresh')
|
||||
if refresh:
|
||||
refresh_token = RefreshToken(refresh)
|
||||
refresh_token.blacklist()
|
||||
except TokenError as e:
|
||||
pass
|
||||
|
||||
return Response({"message": "password updated successfully"}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class CheckUserFieldDuplicateAPIView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
# 유저 필드 중복 확인
|
||||
|
||||
Reference in New Issue
Block a user