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 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 등에 사용되는 데코레이터
|
# from .schemas import send_sms_post_schema # Swagger나 drf-spectacular 등에 사용되는 데코레이터
|
||||||
|
|
||||||
INVITE_CHOICE_USE_TYPE ={
|
INVITE_CHOICE_USE_TYPE ={
|
||||||
@@ -29,6 +33,8 @@ INVITE_CHOICE_USE_TYPE ={
|
|||||||
'h': InviteCodeUseType.HACKATHON
|
'h': InviteCodeUseType.HACKATHON
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PASSWORD_RESET_TOKEN_TTL_MINUTES = 5
|
||||||
|
|
||||||
class CertificateService:
|
class CertificateService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send(code, identifier):
|
def send(code, identifier):
|
||||||
@@ -131,3 +137,15 @@ class ProjectInviteService(InviteService):
|
|||||||
return ProjectTeamList.objects.create(user=invitee, project=work)
|
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
|
@transaction.atomic
|
||||||
def patch(self, request):
|
def patch(self, request):
|
||||||
use_type = request.query_params.get("type")
|
use_type = request.query_params.get("type")
|
||||||
|
purpose = request.query_params.get("purpose")
|
||||||
|
|
||||||
if use_type not in CERTIFICATE_SERVICE_USE_TYPE:
|
if use_type not in CERTIFICATE_SERVICE_USE_TYPE:
|
||||||
return Response({"message": "Not defined use_type"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"message": "Not defined use_type"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
serv = CERTIFICATE_SERVICE_USE_TYPE[use_type]
|
serv = CERTIFICATE_SERVICE_USE_TYPE[use_type]
|
||||||
code = request.data.get('code', None)
|
code = request.data.get('code', None)
|
||||||
if not code:
|
if not code:
|
||||||
return Response({"message": "no code"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"message": "no code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
serializer = CertificateCodeSerializer(data=request.data)
|
serializer = CertificateCodeSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
|
||||||
if serv.check_code(use_type, code, serializer.validated_data['identifier']):
|
if not serializer.is_valid():
|
||||||
return Response({"message": "certificated successfully"}, status=status.HTTP_200_OK)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
return Response({"message": "wrong code, please retry"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
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):
|
class InviteByLinkAPIView(APIView):
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ SOLAPI_API_KEY = env('SOLAPI_API_KEY')
|
|||||||
SOLAPI_API_SECRET = env('SOLAPI_API_SECRET')
|
SOLAPI_API_SECRET = env('SOLAPI_API_SECRET')
|
||||||
FROM_PHONE_NUMBER = env('FROM_PHONE_NUMBER')
|
FROM_PHONE_NUMBER = env('FROM_PHONE_NUMBER')
|
||||||
|
|
||||||
|
MAIL_SIGNUP_ENABLED = os.getenv("MAIL_SIGNUP_ENABLED", "0") == "1"
|
||||||
|
|
||||||
DEBUG = env.bool('DEBUG')
|
DEBUG = env.bool('DEBUG')
|
||||||
|
|
||||||
ALLOWED_HOSTS = ['*']
|
ALLOWED_HOSTS = ['*']
|
||||||
@@ -67,6 +69,7 @@ INSTALLED_APPS = [
|
|||||||
'notifications',
|
'notifications',
|
||||||
'nocodetools',
|
'nocodetools',
|
||||||
'homes',
|
'homes',
|
||||||
|
'mail'
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -15,5 +15,10 @@ urlpatterns = [
|
|||||||
path('api/home/', include('homes.urls')),
|
path('api/home/', include('homes.urls')),
|
||||||
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
] + 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:
|
# if settings.DEBUG:
|
||||||
# urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
# 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
|
from django.utils import timezone
|
||||||
|
|
||||||
import mongoengine as me
|
import mongoengine as me
|
||||||
|
|
||||||
|
|
||||||
class Element(me.EmbeddedDocument):
|
class NodeType(me.EmbeddedDocument):
|
||||||
element_id = me.IntField()
|
"""
|
||||||
element_type = me.StringField()
|
"type": { "resolvedName": "CraftFrame" } 형태
|
||||||
content = me.StringField()
|
"""
|
||||||
css = me.DictField()
|
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):
|
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):
|
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)
|
keyword = me.ListField(me.StringField(max_length=50), default=list)
|
||||||
description = me.StringField(default='', blank=True)
|
description = me.StringField(default="")
|
||||||
object_type = me.StringField(choices=("portfolio", "project"))
|
|
||||||
object_id = me.StringField()
|
object_type = me.StringField(choices=("portfolio", "project"), required=True)
|
||||||
|
object_id = me.StringField(required=True)
|
||||||
|
|
||||||
created_at = me.DateTimeField(default=timezone.now)
|
created_at = me.DateTimeField(default=timezone.now)
|
||||||
updated_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):
|
def save(self, *args, **kwargs):
|
||||||
self.updated_at = timezone.now()
|
self.updated_at = timezone.now()
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
@@ -1,30 +1,67 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Code, Page, Element
|
|
||||||
|
|
||||||
# from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from django.utils import 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):
|
class PageSerializer(serializers.Serializer):
|
||||||
cut = serializers.IntegerField()
|
page_id = serializers.IntegerField()
|
||||||
elements = ElementSerializer(many=True)
|
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):
|
class CodeSerializer(serializers.Serializer):
|
||||||
id = serializers.SerializerMethodField()
|
id = serializers.SerializerMethodField()
|
||||||
pages = PageSerializer(many=True, required=False)
|
keyword = serializers.ListField(
|
||||||
keyword = serializers.ListField(child=serializers.CharField(), required=False, allow_empty=True)
|
child=serializers.CharField(max_length=50),
|
||||||
description = serializers.CharField(allow_blank=True, required=False)
|
required=False,
|
||||||
created_at = serializers.SerializerMethodField()
|
allow_empty=True,
|
||||||
updated_at = serializers.SerializerMethodField()
|
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):
|
def get_created_at(self, obj):
|
||||||
return timezone.localtime(obj.created_at).isoformat(timespec="seconds")
|
return timezone.localtime(obj.created_at).isoformat(timespec="seconds")
|
||||||
@@ -32,46 +69,77 @@ class CodeSerializer(serializers.Serializer):
|
|||||||
def get_updated_at(self, obj):
|
def get_updated_at(self, obj):
|
||||||
return timezone.localtime(obj.updated_at).isoformat(timespec="seconds")
|
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):
|
def create(self, validated_data):
|
||||||
request = self.context['request']
|
request = self.context["request"]
|
||||||
validated_data['object_type'] = request.query_params.get('type')
|
|
||||||
validated_data['object_id'] = request.query_params.get('id')
|
validated_data["object_type"] = request.query_params.get("type")
|
||||||
pages_data = validated_data.pop('pages')
|
validated_data["object_id"] = request.query_params.get("id")
|
||||||
pages = [
|
|
||||||
Page(
|
pages_data = validated_data.pop("pages", [])
|
||||||
cut=page['cut'],
|
pages = [self._to_page(p) for p in pages_data]
|
||||||
elements=[Element(**el) for el in page['elements']]
|
|
||||||
) for page in pages_data
|
|
||||||
]
|
|
||||||
code = Code(pages=pages, **validated_data)
|
code = Code(pages=pages, **validated_data)
|
||||||
code.save()
|
code.save()
|
||||||
return code
|
return code
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
update_pages_data = validated_data.get('pages', [])
|
# pages는 page_id 기준으로 upsert
|
||||||
existing = {p.cut: p for p in instance.pages}
|
update_pages_data = validated_data.get("pages", [])
|
||||||
|
existing = {p.page_id: p for p in instance.pages}
|
||||||
|
|
||||||
for page in update_pages_data:
|
for page in update_pages_data:
|
||||||
cut = page.get('cut')
|
pid = page.get("page_id")
|
||||||
if cut and cut in existing:
|
if pid is None:
|
||||||
page_obj = existing[cut]
|
continue
|
||||||
page_obj.elements = [Element(**el) for el in page["elements"]]
|
|
||||||
|
|
||||||
|
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:
|
else:
|
||||||
instance.pages.append(
|
instance.pages.append(new_page_obj)
|
||||||
Page(
|
|
||||||
cut=cut,
|
|
||||||
elements=[Element(**el) for el in page["elements"]]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if 'keyword' in validated_data:
|
if "keyword" in validated_data:
|
||||||
instance.keyword = validated_data['keyword']
|
instance.keyword = validated_data["keyword"]
|
||||||
if 'description' in validated_data:
|
if "description" in validated_data:
|
||||||
instance.description = validated_data['description']
|
instance.description = validated_data["description"]
|
||||||
|
|
||||||
instance.save()
|
instance.save()
|
||||||
return instance
|
return instance
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
|
|||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from .models import Code, Page, Element
|
from .models import Code
|
||||||
from .serializers import CodeSerializer
|
from .serializers import CodeSerializer
|
||||||
from .permissions import IsOwnerOrMemberInCreateAndUpdateAndDelete, IsNotPublished
|
from .permissions import IsOwnerOrMemberInCreateAndUpdateAndDelete, IsNotPublished
|
||||||
from .services import NocodetoolObjectMapService, NocodetoolHitService
|
from .services import NocodetoolObjectMapService, NocodetoolHitService
|
||||||
@@ -24,24 +24,24 @@ class NoCodeToolAPIView(APIView):
|
|||||||
def get(self, request):
|
def get(self, request):
|
||||||
related_type = request.query_params.get("type")
|
related_type = request.query_params.get("type")
|
||||||
related_id = request.query_params.get("id")
|
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)
|
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)
|
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({
|
return Response({
|
||||||
"obj_info": obj_serializer(obj).data,
|
"obj_info": obj_serializer(obj).data,
|
||||||
"codes": CodeSerializer(code).data
|
"codes": CodeSerializer(code).data
|
||||||
}, status=status.HTTP_200_OK)
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
@@ -62,11 +62,11 @@ class NoCodeToolAPIView(APIView):
|
|||||||
|
|
||||||
obj_serializer = NocodetoolObjectMapService.mapping_model_serializer(related_type)
|
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():
|
if serializer.is_valid():
|
||||||
code = serializer.save()
|
code = serializer.save()
|
||||||
obj.code_id = str(code.id)
|
obj.code_id = str(code.id)
|
||||||
obj.save()
|
obj.save(update_fields=["code_id"])
|
||||||
|
|
||||||
if thumbnail_file:
|
if thumbnail_file:
|
||||||
obj.thumbnail = thumbnail_file
|
obj.thumbnail = thumbnail_file
|
||||||
@@ -76,6 +76,7 @@ class NoCodeToolAPIView(APIView):
|
|||||||
"obj_info": obj_serializer(obj).data,
|
"obj_info": obj_serializer(obj).data,
|
||||||
"codes": CodeSerializer(code).data
|
"codes": CodeSerializer(code).data
|
||||||
}, status=status.HTTP_200_OK)
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@@ -92,6 +93,9 @@ class NoCodeToolAPIView(APIView):
|
|||||||
if obj.now_worker != request.user.nickname:
|
if obj.now_worker != request.user.nickname:
|
||||||
return Response({"message": f"{obj.now_worker} is working now"}, status=status.HTTP_400_BAD_REQUEST)
|
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()
|
code = Code.objects.filter(id=ObjectId(obj.code_id)).first()
|
||||||
if not code:
|
if not code:
|
||||||
return Response({'message': 'No code object'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'message': 'No code object'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
@@ -100,6 +104,7 @@ class NoCodeToolAPIView(APIView):
|
|||||||
thumbnail_file = data.pop("thumbnail", None)
|
thumbnail_file = data.pop("thumbnail", None)
|
||||||
|
|
||||||
obj_serializer = NocodetoolObjectMapService.mapping_model_serializer(related_type)
|
obj_serializer = NocodetoolObjectMapService.mapping_model_serializer(related_type)
|
||||||
|
|
||||||
serializer = CodeSerializer(code, data=data, partial=True, context={'request': request})
|
serializer = CodeSerializer(code, data=data, partial=True, context={'request': request})
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
updated_code = serializer.save()
|
updated_code = serializer.save()
|
||||||
@@ -112,6 +117,7 @@ class NoCodeToolAPIView(APIView):
|
|||||||
"obj_info": obj_serializer(obj).data,
|
"obj_info": obj_serializer(obj).data,
|
||||||
"codes": CodeSerializer(updated_code).data
|
"codes": CodeSerializer(updated_code).data
|
||||||
}, status=status.HTTP_200_OK)
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
@@ -128,13 +134,18 @@ class NoCodeToolAPIView(APIView):
|
|||||||
if obj.now_worker != request.user.nickname:
|
if obj.now_worker != request.user.nickname:
|
||||||
return Response({"message": f"{obj.now_worker} is working now"}, status=status.HTTP_400_BAD_REQUEST)
|
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()
|
code = Code.objects.filter(id=ObjectId(obj.code_id)).first()
|
||||||
if not code:
|
if not code:
|
||||||
return Response({'message': 'No code object'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'message': 'No code object'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
code.delete()
|
code.delete()
|
||||||
obj.code_id = None
|
obj.code_id = None
|
||||||
obj.thumbnail = None
|
obj.thumbnail = None
|
||||||
obj.save()
|
obj.save(update_fields=["code_id", "thumbnail"])
|
||||||
|
|
||||||
return Response({"message": "delete code success"}, status=status.HTTP_200_OK)
|
return Response({"message": "delete code success"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
class NocodeToolWorkingAPIView(APIView):
|
class NocodeToolWorkingAPIView(APIView):
|
||||||
|
|||||||
@@ -54,12 +54,15 @@ class PortfolioSetRepresentAPIView(APIView):
|
|||||||
portfolio = get_object_or_404(Portfolio, id=pk)
|
portfolio = get_object_or_404(Portfolio, id=pk)
|
||||||
if user != portfolio.owner:
|
if user != portfolio.owner:
|
||||||
return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN)
|
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
|
is_representing = request.data.get("representing", False)
|
||||||
before_represent.save()
|
if is_representing:
|
||||||
portfolio.is_represent = True
|
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()
|
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):
|
class PortfolioSetPublishAPIView(APIView):
|
||||||
@@ -69,9 +72,10 @@ class PortfolioSetPublishAPIView(APIView):
|
|||||||
portfolio = get_object_or_404(Portfolio, id=pk)
|
portfolio = get_object_or_404(Portfolio, id=pk)
|
||||||
if user != portfolio.owner:
|
if user != portfolio.owner:
|
||||||
return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN)
|
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()
|
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):
|
class PortfolioChangeState(APIView):
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
|||||||
@@ -89,12 +89,15 @@ class ProjectSetRepresentAPIView(APIView):
|
|||||||
project = get_object_or_404(Project, id=pk)
|
project = get_object_or_404(Project, id=pk)
|
||||||
if user != project.owner:
|
if user != project.owner:
|
||||||
return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN)
|
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
|
is_representing = request.data.get("representing", False)
|
||||||
before_represent.save()
|
if is_representing:
|
||||||
project.is_represent = True
|
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()
|
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):
|
class ProjectSetPublishAPIView(APIView):
|
||||||
@@ -104,9 +107,10 @@ class ProjectSetPublishAPIView(APIView):
|
|||||||
project = get_object_or_404(Project, id=pk)
|
project = get_object_or_404(Project, id=pk)
|
||||||
if user != project.owner:
|
if user != project.owner:
|
||||||
return Response({"message": "Not owner"}, status=status.HTTP_403_FORBIDDEN)
|
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()
|
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):
|
class ProjectChangeState(APIView):
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
|||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
@@ -9,3 +9,13 @@ class IsGithubLinked(BasePermission):
|
|||||||
if token := getattr(token_obj, 'access_token', None):
|
if token := getattr(token_obj, 'access_token', None):
|
||||||
return True
|
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('mypage/my-info/', MyPageMemberInfoAPIView.as_view()),
|
||||||
path("github/", include(router.urls)),
|
path("github/", include(router.urls)),
|
||||||
path("portfolio/", UserInfoAPIView.as_view()),
|
path("portfolio/", UserInfoAPIView.as_view()),
|
||||||
|
path("reset-password/", PasswordResetAPIView.as_view()),
|
||||||
]
|
]
|
||||||
@@ -40,6 +40,7 @@ from github import Github, GithubException
|
|||||||
|
|
||||||
CACHE_TIMEOUT = 60 * 60
|
CACHE_TIMEOUT = 60 * 60
|
||||||
PAGE_SIZE = 20
|
PAGE_SIZE = 20
|
||||||
|
PASSWORD_RESET_USED_JTI_TTL = 60 * 10
|
||||||
|
|
||||||
class RefreshAPIView(APIView):
|
class RefreshAPIView(APIView):
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
@@ -388,6 +389,48 @@ class LoginAPIView(APIView):
|
|||||||
else: # id, 비번 둘 중 하나가 틀렸을 때
|
else: # id, 비번 둘 중 하나가 틀렸을 때
|
||||||
return Response({"message": "아이디 혹은 비밀번호가 맞지 않습니다."}, status=status.HTTP_400_BAD_REQUEST)
|
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):
|
class CheckUserFieldDuplicateAPIView(APIView):
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
# 유저 필드 중복 확인
|
# 유저 필드 중복 확인
|
||||||
|
|||||||
Reference in New Issue
Block a user