Compare commits

..

10 Commits

Author SHA1 Message Date
sm4640
f4788f66bc ✏️ Fix: [dev] mail 기능 수정 2026-01-13 09:38:11 +09:00
sm4640
32c11c40bb 🔧 Settings: [dev] docker 의존성 추가 2026-01-13 09:19:09 +09:00
sm4640
6370f50aca ✏️ Fix: [dev] mail 기능 수정 2026-01-13 09:18:27 +09:00
NKEY
caa52fbd0b Merge pull request #104 from plers-org/sm/#103
 Feat: [#103] mail 서버 계정 기능 완료
2026-01-12 23:15:30 +09:00
sm4640
ee7e98235a Feat: [#103] mail 서버 계정 기능 완료 2026-01-12 23:13:22 +09:00
NKEY
d9ac5578c9 Merge pull request #102 from plers-org/sm/#101
 Feat: [#101] 비밀번호 재설정 기능 구현 완료
2026-01-09 17:20:14 +09:00
sm4640
a4fa5d152b Feat: [#101] 비밀번호 재설정 기능 구현 완료 2026-01-09 17:18:14 +09:00
NKEY
41c1aa7013 Merge pull request #100 from plers-org/sm/#99
Sm/#99
2025-12-29 21:32:52 +09:00
sm4640
0d129d14fd ✏️ Fix: [#99] 포폴, 플젝 대표/퍼블리싱 수정 및 노코드툴 스키마 수정 2025-12-29 21:30:50 +09:00
sm4640
196498d879 ✏️ Fix: [#99] 노코드툴 모델 대규모 수정 2025-12-29 17:46:26 +09:00
21 changed files with 678 additions and 110 deletions

View File

@@ -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)

View File

@@ -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):

View File

@@ -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 = [

View File

@@ -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
View File

3
mail/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
mail/apps.py Normal file
View 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
View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

8
mail/urls.py Normal file
View 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
View 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)

View File

@@ -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)

View File

@@ -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:
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"]]
)
)
if 'keyword' in validated_data: for page in update_pages_data:
instance.keyword = validated_data['keyword'] pid = page.get("page_id")
if 'description' in validated_data: if pid is None:
instance.description = validated_data['description'] 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() instance.save()
return instance return instance

View File

@@ -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,25 +24,25 @@ 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):
related_type = request.query_params.get("type") related_type = request.query_params.get("type")
@@ -50,32 +50,33 @@ class NoCodeToolAPIView(APIView):
obj = NocodetoolObjectMapService.mapping_model_instance(related_type, related_id) obj = NocodetoolObjectMapService.mapping_model_instance(related_type, related_id)
if not obj: if not obj:
return Response({"message": "No object"}, status=status.HTTP_400_BAD_REQUEST) return Response({"message": "No object"}, status=status.HTTP_400_BAD_REQUEST)
if not obj.now_worker: if not obj.now_worker:
return Response({"message": "start edit first"}, status=status.HTTP_400_BAD_REQUEST) return Response({"message": "start edit first"}, status=status.HTTP_400_BAD_REQUEST)
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)
data = request.data.copy() data = request.data.copy()
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(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
obj.save(update_fields=["thumbnail"]) obj.save(update_fields=["thumbnail"])
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)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@transaction.atomic @transaction.atomic
@@ -85,21 +86,25 @@ class NoCodeToolAPIView(APIView):
obj = NocodetoolObjectMapService.mapping_model_instance(related_type, related_id) obj = NocodetoolObjectMapService.mapping_model_instance(related_type, related_id)
if not obj: if not obj:
return Response({"message": "No object"}, status=status.HTTP_400_BAD_REQUEST) return Response({"message": "No object"}, status=status.HTTP_400_BAD_REQUEST)
if not obj.now_worker: if not obj.now_worker:
return Response({"message": "start edit first"}, status=status.HTTP_400_BAD_REQUEST) return Response({"message": "start edit first"}, status=status.HTTP_400_BAD_REQUEST)
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)
data = request.data.copy() data = request.data.copy()
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,8 +117,9 @@ 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
def delete(self, request): def delete(self, request):
related_type = request.query_params.get("type") related_type = request.query_params.get("type")
@@ -121,20 +127,25 @@ class NoCodeToolAPIView(APIView):
obj = NocodetoolObjectMapService.mapping_model_instance(related_type, related_id) obj = NocodetoolObjectMapService.mapping_model_instance(related_type, related_id)
if not obj: if not obj:
return Response({"message": "No object"}, status=status.HTTP_400_BAD_REQUEST) return Response({"message": "No object"}, status=status.HTTP_400_BAD_REQUEST)
if not obj.now_worker: if not obj.now_worker:
return Response({"message": "start edit first"}, status=status.HTTP_400_BAD_REQUEST) return Response({"message": "start edit first"}, status=status.HTTP_400_BAD_REQUEST)
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):

View File

@@ -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

View File

@@ -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

Binary file not shown.

View File

@@ -8,4 +8,14 @@ class IsGithubLinked(BasePermission):
if token_obj := getattr(user, 'github_token', None): if token_obj := getattr(user, 'github_token', None):
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"

View File

@@ -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()),
] ]

View File

@@ -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]
# 유저 필드 중복 확인 # 유저 필드 중복 확인