본문 바로가기

명사 美 비격식 (무리 중에서) 아주 뛰어난[눈에 띄는] 사람[것]

Portfolio

S brain - front - Scaffolding - comming soon

프로젝트 전중후 좋은 아이디어가 생각났을경우 지속적인 회의 등을 거치는것도 좋지만
자칫 상대의 집중시간을 방해하거나 채택되지않을 경우 일정에 영향을 줄 수 있다. 
comming soon 화면을 생성해 아이디어를 push하면 모두에게 공유가 되고 파일형으로도 자유롭게 따로 저장도 할 수 있도록 구현해보자. 
 
config의 urls.py에 comming-soon/은 apps.commingsoon.urls를 include하도록 setting 되어있고
apps/commingsoon/urls.py에는 comming soon 페이지와, comming soon에 들어갈 아이디어 목록, 상세, 수정, 삭제 기능을 넣자.

from django.urls import path

from apps.comingsoon import views

app_name = 'comingsoon'

urlpatterns = [
    path('', views.index, name='index'),
    path('blocked/', views.blocked, name='blocked'),
    path('manage/', views.manage, name='manage'),
    path('manage/new/', views.editor, name='editor_new'),
    path('manage/<slug:slug>/edit/', views.editor, name='editor'),
    path('manage/<slug:slug>/delete/', views.delete, name='delete'),
    path('<slug:slug>/', views.detail, name='detail'),
]

 
 
 
기능 미리보기에는 모두가 접근이 가능하나 관리는 관리자만 가능하다 .

 
 
 
목록화면

 
 
 
storage.py에는 태그, 메타데이터, 파일 저장폴더, 삭제파일폴더, url용 slug만들기, 마크다운 만들기, 목록, 상세, 추가/수정, 삭제를 정의했다.

"""Coming Soon 문서 파일 저장소.

기획안에 따라 DB가 아닌 Markdown 파일로 추가 / 수정 / 삭제를 관리한다.
삭제한 문서는 지우지 않고 deleted 폴더로 이동한다.
"""

import re
from datetime import date

import yaml
from django.conf import settings

STATUS_CHOICES = ['아이디어', '연구 예정', '개발 예정', '보류', '완료']
CATEGORY_CHOICES = ['Agent', 'LLM', 'Data', 'Engineering', 'Infra', 'ETC']

# 모든 문서가 공유하는 메타데이터 공통 form
META_FORM = {
    'title': '',
    'summary': '',
    'status': '아이디어',
    'category': 'Agent',
    'technologies': [],
    'related_agents': [],
    'created_at': '',
    'updated_at': '',
}

FRONT_MATTER_RE = re.compile(r'^---\s*\n(.*?)\n---\s*\n?(.*)$', re.DOTALL)


def _root():
    path = settings.COMING_SOON_DIR
    path.mkdir(parents=True, exist_ok=True)
    return path


def _deleted_root():
    path = settings.COMING_SOON_DELETED_DIR
    path.mkdir(parents=True, exist_ok=True)
    return path


def slugify(title):
    slug = re.sub(r'[^0-9A-Za-z가-힣]+', '-', title).strip('-').lower()
    if not re.fullmatch(r'[-a-zA-Z0-9_]+', slug):
        raise ValueError(
            '제목에 한글 또는 URL에서 사용할 수 없는 문자가 포함되어 있습니다.'
        )

    return slug

def _split_front_matter(text):
    match = FRONT_MATTER_RE.match(text)
    if not match:
        return {}, text
    meta = yaml.safe_load(match.group(1)) or {}
    return meta, match.group(2)


def _dump(meta, body):
    front = yaml.safe_dump(meta, allow_unicode=True, sort_keys=False).strip()
    return f'---\n{front}\n---\n\n{body.strip()}\n'


def _to_list(value):
    if isinstance(value, list):
        return [str(item).strip() for item in value if str(item).strip()]
    if not value:
        return []
    return [item.strip() for item in str(value).split(',') if item.strip()]


def normalize(meta):
    normalized = dict(META_FORM)
    normalized.update({key: value for key, value in meta.items() if key in META_FORM})
    normalized['technologies'] = _to_list(normalized['technologies'])
    normalized['related_agents'] = _to_list(normalized['related_agents'])
    return normalized


def list_documents(keyword='', status='', category=''):
    documents = []
    for path in sorted(_root().glob('*.md')):
        document = read_document(path.stem)
        if not document:
            continue
        if keyword and keyword.lower() not in (document['title'] + document['summary']).lower():
            continue
        if status and document['status'] != status:
            continue
        if category and document['category'] != category:
            continue
        documents.append(document)
    return documents


def read_document(slug):
    path = _root() / f'{slug}.md'
    if not path.exists():
        return None
    meta, body = _split_front_matter(path.read_text(encoding='utf-8'))
    document = normalize(meta)
    document['slug'] = slug
    document['body'] = body.strip()
    return document


def save_document(slug, meta, body):
    document = normalize(meta)
    today = date.today().isoformat()
    document['created_at'] = document['created_at'] or today
    document['updated_at'] = today

    new_slug = slug or slugify(document['title'])    
    path = _root() / f'{new_slug}.md'
    path.write_text(_dump(document, body), encoding='utf-8')
    return new_slug


def delete_document(slug):
    path = _root() / f'{slug}.md'
    if not path.exists():
        return False
    target = _deleted_root() / f'{slug}-{date.today().isoformat()}.md'
    path.rename(target)
    return True

 
 
 
 
views.py에는 각 기능을 정의했다.
markdown을 html로 변환하고 모든 함수는 @login_required 로그인이 필수로 index(), detail(), blocked(), manage(), editor()
storage 객체 read_document, save_document, delete_document 와 templates view를 연결한다. 

import markdown
from django.http import Http404
from django.shortcuts import redirect, render

from apps.accounts.session import admin_required, login_required
from apps.comingsoon import storage


def _render_markdown(body):
    return markdown.markdown(body, extensions=['fenced_code', 'tables'])


@login_required
def index(request):
    context = {
        'documents': storage.list_documents(),
        'active_menu': 'comingsoon',
    }
    return render(request, 'comingsoon/index.html', context)


@login_required
def detail(request, slug):
    document = storage.read_document(slug)
    if not document:
        raise Http404('coming soon document not found')
    context = {
        'document': document,
        'body_html': _render_markdown(document['body']),
        'active_menu': 'comingsoon',
    }
    return render(request, 'comingsoon/detail.html', context)


@login_required
def blocked(request):
    """관리자 전용 메뉴에 접근한 일반 / 체험 사용자 안내 화면."""
    return render(request, 'comingsoon/blocked.html', {'active_menu': 'comingsoon'})


@admin_required
def manage(request):
    keyword = request.GET.get('q', '').strip()
    status = request.GET.get('status', '')
    category = request.GET.get('category', '')
    context = {
        'documents': storage.list_documents(keyword, status, category),
        'filters': {'q': keyword, 'status': status, 'category': category},
        'statuses': storage.STATUS_CHOICES,
        'categories': storage.CATEGORY_CHOICES,
        'active_menu': 'comingsoon_manage',
    }
    return render(request, 'comingsoon/manage.html', context)


@admin_required
def editor(request, slug=None):
    document = storage.read_document(slug) if slug else dict(storage.META_FORM, slug='', body='')
    if slug and not document:
        raise Http404('coming soon document not found')

    if request.method == 'POST':
        meta = {
            'title': request.POST.get('title', '').strip(),
            'summary': request.POST.get('summary', '').strip(),
            'status': request.POST.get('status', storage.STATUS_CHOICES[0]),
            'category': request.POST.get('category', storage.CATEGORY_CHOICES[0]),
            'technologies': request.POST.get('technologies', ''),
            'related_agents': request.POST.get('related_agents', ''),
            'created_at': document.get('created_at', ''),
        }
        new_slug = storage.save_document(slug, meta, request.POST.get('body', ''))
        return redirect('comingsoon:editor', slug=new_slug)

    context = {
        'document': document,
        'statuses': storage.STATUS_CHOICES,
        'categories': storage.CATEGORY_CHOICES,
        'active_menu': 'comingsoon_manage',
    }
    return render(request, 'comingsoon/editor.html', context)


@admin_required
def delete(request, slug):
    if request.method == 'POST':
        storage.delete_document(slug)
    return redirect('comingsoon:manage')