Compare commits

...

3 Commits

Author SHA1 Message Date
1d38fe26bd minio 테스트 인증정보 삭제, 변수 처리 완료
All checks were successful
Build And Test / build-and-push (push) Successful in 4m25s
2025-01-26 01:16:26 +09:00
d8111f6070 minio이미지 업로드 기능 테스트 2025-01-25 22:45:27 +09:00
af57b56e69 공지사항관리 기능 분리 및 개선
All checks were successful
Build And Test / build-and-push (push) Successful in 4m23s
2025-01-25 18:02:49 +09:00
49 changed files with 1265 additions and 198 deletions

BIN
README-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

@ -1,6 +1,57 @@
# butler_ddochi # butler_ddochi
### dev_0.0.21 butler_ddochi는 개인 테스트 환경 관리를 위한 목적으로 만들어지고 있습니다.
사이드메뉴에 unity 게임 등록. * 클라우드 네이티브 환경을 이용한 서비스 환경 관리
* 쿠버네티스에 배포된 웹 게임 링크 등록. * 쿠버네티스 관리
* 쿠버네티스 클러스터에 배포된 웹 게임 및 istio 설정 필요. * 문의는 icurfer@gmail.com으로 부탁드립니다.
> 문서가 미흡하여 설치 및 구성이 어려울 수 있습니다.
---
## 목차
* [사전 구성요소 선택](#사전-구성-컴포넌트(선택))
* [butler_ddochi설치](#butler_ddochi설치)
### 사전 구성 컴포넌트(필수)
동작을 위한 최소한의 필수 컴포넌트입니다.
* MariaDB
* MinIO
### 사전 구성 컴포넌트(선택)
아래 컴포넌트들이 배포 되어있다면, 로그인 후 Edit Profile을 이용하여 웹콘솔 링크를 입력하여 사용합니다.
![alt text](README-1.png)
* Gitea
* Harbor
* ArgoCD
* CodeServer
* Rancher
* Prometheus
* OpenSearch
* Kiali
---
### butler_ddochi설치
docker 또는 kubernetes를 이용하여 배포 합니다.
> docker pull harbor.icurfer.com/py_prj/butler_ddochi:${version}
필수 환경변수
* DEBUG=0
* SQL_ENGINE='django.db.backends.mysql'
* SQL_HOST='DB_SVR_address'
* SQL_USER='DB_SVR_user'
* SQL_PASSWORD='DB_SVR_user_password'
* SQL_DATABASE='DB_SVR_database'
* SQL_PORT='DB_SVR_portnumber'
* CSRF_TRUSTED_ORIGINS=https://www.example.com,https://butler.example.com,https://butler.example.com:8000
* MM_URL=https://mm.example.com/hooks/${hash}
* (기능 미구현)MINIO_URL=https://minio.example.com/
## 특이사항
### markdown에디터에 삽입된 이미지 렌더링할때 404에러가 발생하지만, 실제 동작은 잘 되는 이유.
* MinIO와 연결하여 이미지 저장 기능을 사용 할 수 있도록 구현되어 있습니다.
* Bucket을 private로 설정하는 것을 선호하므로 Presigned URL로 호출해 오도록 설정되어 있습니다.
* 게시물에는 Presigned URL이 아닌 버킷에 저장된 이미지의 이름만 들어있으므로 get_presigned_url을 이용하여 Presigned URL을 호출하기전에 렌더링이 발생하는 에러 메세지입니다.
* 에러가 아니니 기능상 문제 없이 사용 가능합니다.

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.14 on 2025-01-25 14:39
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0005_rename_content_post_contents'),
]
operations = [
migrations.AlterField(
model_name='post',
name='author',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='blog_posts', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -1,5 +1,6 @@
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.urls import reverse
from markdown_it import MarkdownIt from markdown_it import MarkdownIt
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
@ -10,12 +11,20 @@ class Post(models.Model):
created_at = models.DateTimeField(auto_now_add=True) # 작성일 created_at = models.DateTimeField(auto_now_add=True) # 작성일
updated_at = models.DateTimeField(auto_now=True) # 수정일 updated_at = models.DateTimeField(auto_now=True) # 수정일
tags = TaggableManager() tags = TaggableManager()
author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE) author = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.CASCADE,
related_name='blog_posts'
)
def render_markdown(self): def render_markdown(self):
"""마크다운을 HTML로 변환""" """마크다운을 HTML로 변환"""
md = MarkdownIt() md = MarkdownIt()
return md.render(self.contents) return md.render(self.contents)
def get_absolute_url(self):
return reverse('blog:post_detail', args=[str(self.pk)])
def __str__(self): def __str__(self):
return self.title return self.title

View File

@ -1,8 +1,8 @@
{% extends "components/base.html" %} {% extends "components/base.html" %}
{% block title %}Create Notice{% endblock %} {% block title %}Create Post{% endblock %}
{% block main_area %} {% block main_area %}
<h1 class="pt-3">Create New Notice</h1> <h1 class="pt-3">Create New Post</h1>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
@ -12,6 +12,14 @@
{{ form.title.label_tag }} {{ form.title.label_tag }}
{{ form.title }} {{ form.title }}
</div> </div>
<div class="mb-3">
{{ form.summary.label_tag }}
{{ form.summary }}
</div>
<div class="mb-3">
{{ form.tags.label_tag }}
{{ form.tags }}
</div>
<!-- 마크다운 에디터 --> <!-- 마크다운 에디터 -->
<h2>contents</h2> <h2>contents</h2>
@ -22,7 +30,7 @@
<!-- 버튼 --> <!-- 버튼 -->
<div class="d-flex justify-content-end mt-4"> <div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Create Post</button> <button type="submit" class="btn btn-primary me-2">Create Post</button>
<a href="{% url 'butler:notice_list' %}" class="btn btn-secondary">Cancel</a> <a href="{% url 'blog:post_list' %}" class="btn btn-secondary">Cancel</a>
</div> </div>
</form> </form>
</div> </div>

View File

@ -1,39 +0,0 @@
{% extends "components/base.html" %}
{% block title %}Create Post{% endblock %}
{% block main_area %}
<h1>Create New Post</h1>
<div class="row">
<!-- 왼쪽: 마크다운 에디터 -->
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary mt-3">Save</button>
</form>
</div>
<!-- 오른쪽: 미리보기 -->
<div class="col-md-6">
<h2>Preview</h2>
<div id="preview" class="border p-3" style="background: #f8f9fa; min-height: 300px;"></div>
</div>
</div>
<!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<script>
// 마크다운 파서 초기화
const md = window.markdownit();
// content 필드와 미리보기 연결
const textarea = document.getElementById("markdown-editor");
const preview = document.getElementById("preview");
// 실시간 미리보기 업데이트
textarea.addEventListener("input", function () {
const markdownContent = textarea.value; // textarea 내용 가져오기
preview.innerHTML = md.render(markdownContent); // 마크다운 -> HTML 변환
});
</script>
{% endblock %}

View File

@ -22,11 +22,9 @@
</div> </div>
<!-- 마크다운 에디터 --> <!-- 마크다운 에디터 -->
<h2>contents</h2> <h2>Contents</h2>
<textarea id="markdown-editor" name="contents" class="form-control" rows="10"></textarea>
<div class="col-md-12">
{{ form.contents }}
</div>
<!-- 버튼 --> <!-- 버튼 -->
<div class="d-flex justify-content-end mt-4"> <div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Create Post</button> <button type="submit" class="btn btn-primary me-2">Create Post</button>
@ -41,18 +39,116 @@
<!-- 마크다운 파서 및 스크립트 --> <!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script> <script>
// 마크다운 파서 초기화 // 마크다운 파서 초기화
const md = window.markdownit(); const md = window.markdownit({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs
.highlight(str, {language: lang})
.value;
} catch (__) {}
}
return ''; // 기본 HTML 이스케이프 처리
}
});
// content 필드와 미리보기 연결 // content 필드와 미리보기 연결
const textarea = document.getElementById("markdown-editor"); const textarea = document.getElementById("markdown-editor");
const preview = document.getElementById("preview"); const preview = document.getElementById("preview");
// 미리보기 업데이트 함수
async function updatePreview() {
const markdownContent = textarea.value;
const lines = markdownContent.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/!\[Image\]\((.+)\)/);
if (match) {
const objectName = match[1];
try {
// Presigned URL 가져오기
const response = await fetch("/obs_minio/get_presigned_url/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({object_name: objectName})
});
if (response.ok) {
const data = await response.json();
const presignedUrl = data.presigned_url;
lines[i] = `![Image](${presignedUrl})`; // Presigned URL로 업데이트
}
} catch (error) {
console.error("Error fetching presigned URL:", error);
}
}
}
// 마크다운 렌더링 및 미리보기 업데이트
const renderedContent = md.render(lines.join("\n"));
preview.innerHTML = renderedContent;
// Highlight.js로 코드 블록 강조
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
}
// 실시간 미리보기 업데이트 // 실시간 미리보기 업데이트
textarea.addEventListener("input", function () { textarea.addEventListener("input", updatePreview);
const markdownContent = textarea.value; // textarea 내용 가져오기
preview.innerHTML = md.render(markdownContent); // 마크다운 -> HTML 변환 // Ctrl+V로 이미지 붙여넣기 처리
textarea.addEventListener("paste", async function (event) {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (!file)
return;
const formData = new FormData();
formData.append("image", file);
try {
// 이미지 업로드 API 호출
const response = await fetch("/obs_minio/upload/", {
method: "POST",
body: formData
});
if (response.ok) {
const data = await response.json();
const fullImageUrl = data.url; // 전체 URL
const objectName = fullImageUrl
.split("/")
.slice(-2)
.join("/");
// 마크다운 에디터에 이미지 삽입
const markdownImage = `![Image](${objectName})\n`;
textarea.value += markdownImage;
// 미리보기 업데이트
updatePreview();
} else {
alert("Image upload failed. Please try again.");
}
} catch (error) {
console.error("Error uploading image:", error);
alert("An error occurred during image upload.");
}
}
}
}); });
</script> </script>
{% endblock %} </div>
{% endblock %}

View File

@ -0,0 +1,80 @@
{% extends "components/base.html" %}
{% block title %}Create Post{% endblock %}
{% block main_area %}
<h1 class="pt-3">Create New Post</h1>
<div class="container">
<div class="row">
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
<div class="mb-3">
{{ form.title.label_tag }}
{{ form.title }}
</div>
<div class="mb-3">
{{ form.summary.label_tag }}
{{ form.summary }}
</div>
<div class="mb-3">
{{ form.tags.label_tag }}
{{ form.tags }}
</div>
<!-- 마크다운 에디터 -->
<h2>Contents</h2>
<div class="col-md-12">
{{ form.contents }}
</div>
<!-- 버튼 -->
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Create Post</button>
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div>
</div>
</div>
<!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
// 마크다운 파서 초기화
const md = window.markdownit({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs
.highlight(str, {language: lang})
.value;
} catch (__) {}
}
return ''; // 기본 HTML 이스케이프 처리
}
});
// content 필드와 미리보기 연결
const textarea = document.getElementById("markdown-editor");
const preview = document.getElementById("preview");
// 실시간 미리보기 업데이트
textarea.addEventListener("input", function () {
const markdownContent = textarea.value; // textarea 내용 가져오기
const renderedContent = md.render(markdownContent); // 마크다운 -> HTML 변환
preview.innerHTML = renderedContent;
// Highlight.js로 코드 블록 강조
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
});
</script>
</div>
{% endblock %}

View File

@ -0,0 +1,128 @@
{% extends "components/base.html" %}
{% block title %}Create Post{% endblock %}
{% block main_area %}
<h1 class="pt-3">Create New Post</h1>
<div class="container">
<div class="row">
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
<div class="mb-3">
{{ form.title.label_tag }}
{{ form.title }}
</div>
<div class="mb-3">
{{ form.summary.label_tag }}
{{ form.summary }}
</div>
<div class="mb-3">
{{ form.tags.label_tag }}
{{ form.tags }}
</div>
<!-- 마크다운 에디터 -->
<h2>Contents</h2>
<textarea id="markdown-editor" name="contents" class="form-control" rows="10"></textarea>
<!-- 버튼 -->
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Create Post</button>
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div>
</div>
</div>
<!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
// 마크다운 파서 초기화
const md = window.markdownit({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs
.highlight(str, {language: lang})
.value;
} catch (__) {}
}
return ''; // 기본 HTML 이스케이프 처리
}
});
// content 필드와 미리보기 연결
const textarea = document.getElementById("markdown-editor");
const preview = document.getElementById("preview");
// 실시간 미리보기 업데이트
textarea.addEventListener("input", function () {
const markdownContent = textarea.value; // textarea 내용 가져오기
const renderedContent = md.render(markdownContent); // 마크다운 -> HTML 변환
preview.innerHTML = renderedContent;
// Highlight.js로 코드 블록 강조
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
});
// Ctrl+V로 이미지 붙여넣기 처리
textarea.addEventListener("paste", async function (event) {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (!file)
return;
const formData = new FormData();
formData.append("image", file);
try {
// 이미지 업로드 API 호출
const response = await fetch("/obs_minio/upload/", {
method: "POST",
body: formData
});
if (response.ok) {
const data = await response.json();
const imageUrl = data.url; // 업로드된 이미지 URL
// 마크다운 에디터에 이미지 삽입
const markdownImage = `![Image](${imageUrl})\n`;
textarea.value += markdownImage;
// 미리보기 업데이트
const markdownContent = textarea.value;
const renderedContent = md.render(markdownContent);
preview.innerHTML = renderedContent;
// Highlight.js로 코드 블록 강조
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
} else {
alert("Image upload failed. Please try again.");
}
} catch (error) {
console.error("Error uploading image:", error);
alert("An error occurred during image upload.");
}
}
}
});
</script>
</div>
{% endblock %}

View File

@ -8,9 +8,12 @@
<h5 class="text-muted">{{ post.summary }}</h5> <h5 class="text-muted">{{ post.summary }}</h5>
{% endif %} {% endif %}
<!-- Author --> <!-- Author -->
<div class="text-muted fst-italic mb-2">{{ post.created_at }} <br> <div class="text-muted fst-italic mb-2">{{ post.created_at }}
by. {{ post.author | upper }}</div> <br>
<div> by.
{{ post.author | upper }}</div>
<!-- Rendered Markdown Content -->
<div id="post-content">
{{ post.render_markdown|safe }} {{ post.render_markdown|safe }}
</div> </div>
<hr> <hr>
@ -22,5 +25,84 @@
<br/> <br/>
<br/> <br/>
{% endif %} {% endif %}
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary mt-3">Back to List</a>
{% endblock %} {% if request.user == post.author %}
<div class="d-flex mt-3">
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary me-2">List</a>
<a href="{% url 'blog:update_post' post.pk %}" class="btn btn-warning me-2">Edit</a>
<form method="POST" action="{% url 'blog:delete_post' post.pk %}" class="d-inline" id="delete-form">
{% csrf_token %}
<button type="button" class="btn btn-danger" id="delete-button">Delete</button>
</form>
</div>
{% endif %}
<!-- Highlight.js Styles -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<!-- Highlight.js Script -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
// Initialize Highlight.js
document.addEventListener("DOMContentLoaded", () => {
document
.querySelectorAll("pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
});
// Function to update images with presigned URLs
async function updateImagesWithPresignedUrls() {
const contentDiv = document.getElementById("post-content");
const contentHtml = contentDiv.innerHTML;
const parser = new DOMParser();
const doc = parser.parseFromString(contentHtml, "text/html");
const images = doc.querySelectorAll("img");
for (const img of images) {
const objectName = img.getAttribute("src"); // src에 저장된 object_name
try {
const response = await fetch("/obs_minio/get_presigned_url/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({object_name: objectName})
});
if (response.ok) {
const data = await response.json();
img.setAttribute("src", data.presigned_url); // Presigned URL로 src 업데이트
}
} catch (error) {
console.error("Error fetching presigned URL:", error);
}
}
// 업데이트된 HTML을 다시 삽입
contentDiv.innerHTML = doc.body.innerHTML;
// Reapply syntax highlighting
document
.querySelectorAll("pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
}
// Call the function when the page loads
document.addEventListener("DOMContentLoaded", updateImagesWithPresignedUrls);
// Delete post
document
.getElementById("delete-button")
.addEventListener("click", function () {
const confirmed = confirm("Are you sure you want to delete this post?");
if (confirmed) {
document
.getElementById("delete-form")
.submit();
}
});
</script>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "components/base.html" %}
{% block title %}Post{% endblock %}
{% block main_area %}
<!-- Title -->
<h1 class="mt-4">{{ post.title }}</h1>
{% if post.summary %}
<h5 class="text-muted">{{ post.summary }}</h5>
{% endif %}
<!-- Author -->
<div class="text-muted fst-italic mb-2">{{ post.created_at }}
<br>
by.
{{ post.author | upper }}</div>
<div>
{{ post.render_markdown|safe }}
</div>
<hr>
{% if post.tags.exists %}
<i class="fa-solid fa-tags"></i>
{% for tag in post.tags.all %}
<span class="badge bg-primary">{{ tag }}</span>
{% endfor %}
<br/>
<br/>
{% endif %}
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary mt-3">List</a>
{% if request.user == post.author %}
<a href="{% url 'blog:update_post' post.pk %}" class="btn btn-warning">Edit</a>
{% endif %}
{% endblock %}

View File

@ -12,7 +12,7 @@
<li class="list-group-item"> <li class="list-group-item">
<h5>{{ post.title }}</h5> <h5>{{ post.title }}</h5>
<p>{{ post.summary }}</p> <p>{{ post.summary }}</p>
<a href="{% url 'blog:post_detail' post.pk %}" class="btn btn-secondary">Read More</a> <a href="{{ post.get_absolute_url }}" class="btn btn-secondary">Read More</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -0,0 +1,154 @@
{% extends "components/base.html" %}
{% block title %}Update Post{% endblock %}
{% block main_area %}
<h1 class="pt-3">Update Post</h1>
<div class="container mt-3">
<h5 class="text-danger">&#9671;마크다운 미리보기는 Contents 편집 내용 입력시 동작합니다.</h5>
<div class="row mt-3">
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
<div class="mb-3">
{{ form.title.label_tag }}
{{ form.title }}
</div>
<div class="mb-3">
{{ form.summary.label_tag }}
{{ form.summary }}
</div>
<div class="mb-3">
{{ form.tags.label_tag }}
{{ form.tags }}
</div>
<!-- 마크다운 에디터 -->
<h2>Contents</h2>
<div class="col-md-12">
{{ form.contents }}
</div>
<!-- 버튼 -->
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Save Changes</button>
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div>
</div>
</div>
<!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
// 마크다운 파서 초기화
const md = window.markdownit({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs
.highlight(str, {language: lang})
.value;
} catch (__) {}
}
return ''; // 기본 HTML 이스케이프 처리
}
});
// content 필드와 미리보기 연결
const textarea = document.getElementById("markdown-editor");
const preview = document.getElementById("preview");
// 미리보기 업데이트 함수
async function updatePreview() {
const markdownContent = textarea.value;
const lines = markdownContent.split("\n");
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/!\[Image\]\((.+)\)/);
if (match) {
const objectName = match[1];
try {
const response = await fetch("/obs_minio/get_presigned_url/", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({object_name: objectName})
});
if (response.ok) {
const data = await response.json();
const presignedUrl = data.presigned_url;
lines[i] = `![Image](${presignedUrl})`;
}
} catch (error) {
console.error("Error fetching presigned URL:", error);
}
}
}
const renderedContent = md.render(lines.join("\n"));
preview.innerHTML = renderedContent;
// Highlight.js로 코드 블록 강조
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
}
// 실시간 미리보기 업데이트
textarea.addEventListener("input", updatePreview);
// Ctrl+V로 이미지 붙여넣기 처리
textarea.addEventListener("paste", async function (event) {
const items = (event.clipboardData || event.originalEvent.clipboardData).items;
for (const item of items) {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (!file)
return;
const formData = new FormData();
formData.append("image", file);
try {
// 이미지 업로드 API 호출
const response = await fetch("/obs_minio/upload/", {
method: "POST",
body: formData
});
if (response.ok) {
const data = await response.json();
const fullImageUrl = data.url; // 전체 URL
const objectName = fullImageUrl
.split("/")
.slice(-2)
.join("/");
// 마크다운 에디터에 이미지 삽입
const markdownImage = `![Image](${objectName})\n`;
textarea.value += markdownImage;
// 미리보기 업데이트
updatePreview();
} else {
alert("Image upload failed. Please try again.");
}
} catch (error) {
console.error("Error uploading image:", error);
alert("An error occurred during image upload.");
}
}
}
});
</script>
</div>
{% endblock %}

View File

@ -7,4 +7,6 @@ urlpatterns = [
path('', views.post_list, name='post_list'), # 포스트 리스트 path('', views.post_list, name='post_list'), # 포스트 리스트
path('create/', views.create_post, name='create_post'), # 포스트 작성 path('create/', views.create_post, name='create_post'), # 포스트 작성
path('<int:pk>/', views.post_detail, name='post_detail'), # 포스트 상세 보기 path('<int:pk>/', views.post_detail, name='post_detail'), # 포스트 상세 보기
path('<int:pk>/update/', views.update_post, name='update_post'), # 글 수정 URL
path('<int:pk>/delete/', views.delete_post, name='delete_post'), # 삭제 URL
] ]

View File

@ -3,6 +3,16 @@ from django.contrib.auth.decorators import login_required
from .models import Post from .models import Post
from .forms import PostForm from .forms import PostForm
# 게시글 목록
def post_list(request):
posts = Post.objects.all().order_by('-created_at')
return render(request, 'blog/post_list.html', {'posts': posts})
# 게시글 상세 보기
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
return render(request, 'blog/post_detail.html', {'post': post})
@login_required @login_required
def create_post(request): def create_post(request):
if request.method == 'POST': if request.method == 'POST':
@ -17,10 +27,37 @@ def create_post(request):
form = PostForm() form = PostForm()
return render(request, 'blog/create_post.html', {'form': form}) return render(request, 'blog/create_post.html', {'form': form})
def post_list(request):
posts = Post.objects.all().order_by('-created_at')
return render(request, 'blog/post_list.html', {'posts': posts})
def post_detail(request, pk):
@login_required
def update_post(request, pk):
post = get_object_or_404(Post, pk=pk) post = get_object_or_404(Post, pk=pk)
return render(request, 'blog/post_detail.html', {'post': post})
# 작성자만 수정 가능하도록 권한 검사
if post.author != request.user:
return redirect('blog:post_list')
if request.method == 'POST':
form = PostForm(request.POST, instance=post)
if form.is_valid():
form.save()
return redirect('blog:post_detail', pk=post.pk)
else:
form = PostForm(instance=post)
return render(request, 'blog/update_post.html', {'form': form, 'post': post})
@login_required
def delete_post(request, pk):
post = get_object_or_404(Post, pk=pk)
# 작성자만 삭제 가능
if post.author != request.user:
return redirect('blog:post_list')
# POST 요청일 때 삭제
if request.method == 'POST':
post.delete()
return redirect('blog:post_list')
return render(request, 'blog/confirm_delete.html', {'post': post})

0
board_notice/__init__.py Normal file
View File

4
board_notice/admin.py Normal file
View File

@ -0,0 +1,4 @@
from django.contrib import admin
from .models import BoardNotice
admin.site.register(BoardNotice)

6
board_notice/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class BoardNoticeConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'board_notice'

11
board_notice/forms.py Normal file
View File

@ -0,0 +1,11 @@
from django import forms
from .models import BoardNotice
class BoardNoticeForm(forms.ModelForm):
class Meta:
model = BoardNotice
fields = ['title', 'contents']
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control'}),
'contents': forms.Textarea(attrs={'class': 'form-control', 'id': 'markdown-editor'}),
}

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.14 on 2025-01-25 14:39
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BoardNotice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('contents', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='board_notice', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

27
board_notice/models.py Normal file
View File

@ -0,0 +1,27 @@
from django.db import models
from django.conf import settings
from django.urls import reverse
from markdown_it import MarkdownIt
class BoardNotice(models.Model):
title = models.CharField(max_length=200) # 제목
contents = models.TextField() # 본문 (마크다운 저장)
created_at = models.DateTimeField(auto_now_add=True) # 작성일
updated_at = models.DateTimeField(auto_now=True) # 수정일
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.CASCADE,
related_name='board_notice'
)
def render_markdown(self):
"""마크다운을 HTML로 변환"""
md = MarkdownIt()
return md.render(self.contents)
def get_absolute_url(self):
return reverse('board_notice:notice_detail', args=[str(self.pk)])
def __str__(self):
return self.title

View File

@ -0,0 +1,72 @@
{% extends "components/base.html" %}
{% block title %}Create Notice{% endblock %}
{% block main_area %}
<h1 class="pt-3">Create New Notice</h1>
<div class="container">
<div class="row">
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
<div class="mb-3">
{{ form.title.label_tag }}
{{ form.title }}
</div>
<!-- 마크다운 에디터 -->
<h2>Contents</h2>
<div class="col-md-12">
{{ form.contents }}
</div>
<!-- 버튼 -->
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Create Notice</button>
<a href="{% url 'board_notice:notice_list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div>
</div>
</div>
<!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
// 마크다운 파서 초기화
const md = window.markdownit({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs
.highlight(str, {language: lang})
.value;
} catch (__) {}
}
return ''; // 기본 HTML 이스케이프 처리
}
});
// content 필드와 미리보기 연결
const textarea = document.getElementById("markdown-editor");
const preview = document.getElementById("preview");
// 실시간 미리보기 업데이트
textarea.addEventListener("input", function () {
const markdownContent = textarea.value; // textarea 내용 가져오기
const renderedContent = md.render(markdownContent); // 마크다운 -> HTML 변환
preview.innerHTML = renderedContent;
// Highlight.js로 코드 블록 강조
document
.querySelectorAll("#preview pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
});
</script>
</div>
{% endblock %}

View File

@ -0,0 +1,56 @@
{% extends "components/base.html" %}
{% block title %}Notice{% endblock %}
{% block main_area %}
<!-- Title -->
<h1 class="mt-4">{{ notice.title }}</h1>
<!-- Author -->
<div class="text-muted fst-italic mb-2">{{ notice.created_at }}
<br>
by.
{{ notice.author | upper }}</div>
<!-- Rendered Markdown Content -->
<div>
{{ notice.render_markdown|safe }}
</div>
<hr>
{% if request.user == notice.author %}
<div class="d-flex mt-3">
<a href="{% url 'board_notice:notice_list' %}" class="btn btn-secondary me-2">List</a>
<a href="{% url 'board_notice:update_notice' notice.pk %}" class="btn btn-warning me-2">Edit</a>
<form method="POST" action="{% url 'board_notice:delete_notice' notice.pk %}" class="d-inline" id="delete-form">
{% csrf_token %}
<button type="button" class="btn btn-danger" id="delete-button">Delete</button>
</form>
</div>
{% endif %}
<!-- Highlight.js Styles -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css">
<!-- Highlight.js Script -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js"></script>
<script>
// Initialize Highlight.js
document.addEventListener("DOMContentLoaded", () => {
document
.querySelectorAll("pre code")
.forEach((block) => {
hljs.highlightElement(block);
});
});
</script>
<!-- Delete Notice -->
<script>
document
.getElementById("delete-button")
.addEventListener("click", function () {
const confirmed = confirm("Are you sure you want to delete this Notice?");
if (confirmed) {
document
.getElementById("delete-form")
.submit();
}
});
</script>
{% endblock %}

View File

@ -5,27 +5,31 @@
{% block main_area %} {% block main_area %}
<h2 class="fw-bold pt-3 pb-2 d-flex justify-content-between"> <h2 class="fw-bold pt-3 pb-2 d-flex justify-content-between">
공지사항 공지사항
</h2> </h2>
<!-- 공지사항 목록 --> <!-- 공지사항 목록 -->
{% if records %} {% if notices %}
<table class="table table-bordered table-hover"> <table class="table table-bordered table-hover">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th scope="col">글번호</th> <th scope="col">글번호</th>
<th scope="col">제목</th> <th scope="col">제목</th>
<th scope="col">내용</th>
<th scope="col">작성자</th> <th scope="col">작성자</th>
<th scope="col">작성일</th> <th scope="col">작성일</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for record in records %} {% for record in notices %}
<tr> <tr>
<td>{{ record.pk }}</td> <td>{{ record.pk }}</td>
<td> <td>
<a href="{{ record.get_absolute_url }}">{{ record.title }}</a> <a href="{{ record.get_absolute_url }}">{{ record.title }}</a>
</td> </td>
<td>
<a href="{{ record.get_absolute_url }}">{{ record.contents|truncatechars:50 }}</a>
</td>
<td>{{ record.author }}</td> <td>{{ record.author }}</td>
<td>{{ record.created_at|date:"Y-m-d H:i" }}</td> <td>{{ record.created_at|date:"Y-m-d H:i" }}</td>
</tr> </tr>
@ -36,9 +40,9 @@
<p class="text-muted">현재 공지사항이 없습니다.</p> <p class="text-muted">현재 공지사항이 없습니다.</p>
{% endif %} {% endif %}
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<!-- 버튼 컨테이너 --> <!-- 버튼 컨테이너 -->
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<a href="{% url 'butler:create_notice' %}" class="btn btn-primary">Create Notice</a> <a href="{% url 'board_notice:create_notice' %}" class="btn btn-primary">Create Notice</a>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,58 @@
{% extends "components/base.html" %}
{% block title %}Update Post{% endblock %}
{% block main_area %}
<h1 class="pt-3">Update Post</h1>
<div class="container">
<div class="row">
<div class="col-md-6">
<form method="POST" id="post-form">
{% csrf_token %}
<div class="mb-3">
{{ form.title.label_tag }}
{{ form.title }}
</div>
<div class="mb-3">
{{ form.summary.label_tag }}
{{ form.summary }}
</div>
<div class="mb-3">
{{ form.tags.label_tag }}
{{ form.tags }}
</div>
<!-- 마크다운 에디터 -->
<h2>Contents</h2>
<div class="col-md-12">
{{ form.contents }}
</div>
<!-- 버튼 -->
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary me-2">Save Changes</button>
<a href="{% url 'blog:post_list' %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<div id="preview" class="border p-3 bg-light h-100 overflow-auto"></div>
</div>
</div>
<!-- 마크다운 파서 및 스크립트 -->
<script src="https://cdn.jsdelivr.net/npm/markdown-it/dist/markdown-it.min.js"></script>
<script>
// 마크다운 파서 초기화
const md = window.markdownit();
// content 필드와 미리보기 연결
const textarea = document.getElementById("markdown-editor");
const preview = document.getElementById("preview");
// 실시간 미리보기 업데이트
textarea.addEventListener("input", function () {
const markdownContent = textarea.value; // textarea 내용 가져오기
preview.innerHTML = md.render(markdownContent); // 마크다운 -> HTML 변환
});
</script>
</div>
{% endblock %}

3
board_notice/tests.py Normal file
View File

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

12
board_notice/urls.py Normal file
View File

@ -0,0 +1,12 @@
from django.urls import path
from . import views
app_name = 'board_notice'
urlpatterns = [
path('', views.notice_list, name='notice_list'), # 포스트 리스트
path('create/', views.create_notice, name='create_notice'), # 포스트 작성
path('<int:pk>/', views.notice_detail, name='notice_detail'), # 포스트 상세 보기
path('<int:pk>/update/', views.update_notice, name='update_notice'), # 글 수정 URL
path('<int:pk>/delete/', views.delete_notice, name='delete_notice'), # 삭제 URL
]

62
board_notice/views.py Normal file
View File

@ -0,0 +1,62 @@
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from .models import BoardNotice
from .forms import BoardNoticeForm
# 게시글 목록
def notice_list(request):
notices = BoardNotice.objects.all().order_by('-created_at')
return render(request, 'board_notice/notice_list.html', {'notices': notices})
# 게시글 상세 보기
def notice_detail(request, pk):
notice = get_object_or_404(BoardNotice, pk=pk)
return render(request, 'board_notice/notice_detail.html', {'notice': notice})
# 공지사항 게시글 등록
@login_required
def create_notice(request):
if request.method == 'POST':
form = BoardNoticeForm(request.POST)
if form.is_valid():
notice = form.save(commit=False)
notice.author = request.user # 작성자 정보 추가
notice.save()
return redirect('board_notice:notice_list')
else:
form = BoardNoticeForm()
return render(request, 'board_notice/create_notice.html', {'form': form})
# 공지사항 게시글 수정
@login_required
def update_notice(request, pk):
notice = get_object_or_404(BoardNotice, pk=pk)
# 작성자만 수정 가능하도록 권한 검사
if notice.author != request.user:
return redirect('board_notice:notice_list')
if request.method == 'POST':
form = BoardNoticeForm(request.POST, instance=notice)
if form.is_valid():
form.save()
return redirect('board_notice:notice_detail', pk=notice.pk)
else:
form = BoardNoticeForm(instance=notice)
return render(request, 'board_notice/update_notice.html', {'form': form, 'notice': notice})
# 공지사항 게시글 삭제
@login_required
def delete_notice(request, pk):
notice = get_object_or_404(BoardNotice, pk=pk)
# 작성자만 삭제 가능하도록 권한 검사
if notice.author != request.user:
return redirect('board_notice:notice_list')
if request.method == 'POST':
notice.delete()
return redirect('board_notice:notice_list')
return render(request, 'board_notice/confirm_delete.html', {'notice': notice})

View File

@ -1,5 +1,4 @@
from django.contrib import admin from django.contrib import admin
from .models import IPManagementRecord, NoticeBoard from .models import IPManagementRecord
admin.site.register(IPManagementRecord) admin.site.register(IPManagementRecord)
admin.site.register(NoticeBoard)

View File

@ -1,11 +1 @@
from django import forms from django import forms
from .models import NoticeBoard
class PostForm(forms.ModelForm):
class Meta:
model = NoticeBoard
fields = ['title', 'contents']
widgets = {
'title': forms.TextInput(attrs={'class': 'form-control'}),
'contents': forms.Textarea(attrs={'class': 'form-control', 'id': 'markdown-editor'}),
}

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.14 on 2025-01-25 11:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('butler', '0002_noticeboard'),
]
operations = [
migrations.RenameField(
model_name='ipmanagementrecord',
old_name='desc',
new_name='contents',
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 4.2.14 on 2025-01-25 19:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('butler', '0003_rename_desc_ipmanagementrecord_contents'),
]
operations = [
migrations.DeleteModel(
name='NoticeBoard',
),
]

View File

@ -7,7 +7,7 @@ class IPManagementRecord(models.Model):
network_nm = models.CharField(max_length=100) # NETWORK_NM network_nm = models.CharField(max_length=100) # NETWORK_NM
ip_addrs = models.CharField(max_length=50) # IP_ADDRS ip_addrs = models.CharField(max_length=50) # IP_ADDRS
svr_nm = models.CharField(max_length=100) # SVR_NM svr_nm = models.CharField(max_length=100) # SVR_NM
desc = models.TextField(blank=True, null=True) # DESC contents = models.TextField(blank=True, null=True) # DESC
remark = models.TextField(blank=True, null=True) # REMARK remark = models.TextField(blank=True, null=True) # REMARK
created_at = models.DateTimeField(auto_now_add=True) # 생성 시간 자동 기록 created_at = models.DateTimeField(auto_now_add=True) # 생성 시간 자동 기록
updated_at = models.DateTimeField(auto_now=True) # 수정 시간 자동 기록 updated_at = models.DateTimeField(auto_now=True) # 수정 시간 자동 기록
@ -15,21 +15,4 @@ class IPManagementRecord(models.Model):
author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE) author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)
def __str__(self): def __str__(self):
return f'{self.network_nm} {self.ip_addrs} {self.svr_nm} {self.desc}' return f'{self.network_nm} {self.ip_addrs} {self.svr_nm} {self.contents}'
class NoticeBoard(models.Model):
title = models.CharField(max_length=100)
contents = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True) # 생성 시간 자동 기록
updated_at = models.DateTimeField(auto_now=True) # 수정 시간 자동 기록
author = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)
def __str__(self):
return f'{self.title} {self.author} {self.created_at}'
def get_absolute_url(self):
return reverse('butler:notice_detail', args=[str(self.pk)])
# <a href="{% url 'butler:notice_detail' record.pk %}">{{ record.title }}</a>

View File

@ -3,46 +3,46 @@
{% block title %}Landing Page{% endblock %} {% block title %}Landing Page{% endblock %}
{% block main_area %} {% block main_area %}
<div class="container mt-5"> <div class="container mt-5">
<div class="row"> <div class="row">
<!-- NoticeBoard Section --> <!-- NoticeBoard Section -->
<div class="col-md-6"> <div class="col-md-6">
<h2>Latest Notices</h2> <h2>Latest Notices</h2>
<div class="row"> <div class="row">
{% for notice in notices %} {% for notice in board_notices %}
<div class="col-12 mb-3"> <div class="col-12 mb-3">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">{{ notice.title }}</h5> <h5 class="card-title">{{ notice.title }}</h5>
<p class="card-text">{{ notice.content|truncatewords:20 }}</p> <p class="card-text">{{ notice.contents|truncatechars:50 }}</p>
<a href="{% url 'butler:notice_detail' notice.id %}" class="btn btn-primary">View Notice</a> <a href="{{ notice.get_absolute_url }}" class="btn btn-primary">View Notice</a>
</div>
</div>
</div> </div>
</div> {% empty %}
<p>No notices available.</p>
{% endfor %}
</div> </div>
{% empty %}
<p>No notices available.</p>
{% endfor %}
</div> </div>
</div> <!-- Blog Posts Section -->
<!-- Blog Posts Section --> <div class="col-md-6">
<div class="col-md-6"> <h2>Latest Blog Posts</h2>
<h2>Latest Blog Posts</h2> <div class="row">
<div class="row"> {% for post in blog_posts %}
{% for post in blog_posts %} <div class="col-12 mb-3">
<div class="col-12 mb-3"> <div class="card">
<div class="card"> <div class="card-body">
<div class="card-body"> <h5 class="card-title">{{ post.title }}</h5>
<h5 class="card-title">{{ post.title }}</h5> <p class="card-text">{{ post.contents|truncatechars:50 }}</p>
<p class="card-text">{{ post.summary|truncatewords:20 }}</p> <a href="{% url 'blog:post_detail' post.id %}" class="btn btn-primary">Read More</a>
<a href="{% url 'blog:post_detail' post.id %}" class="btn btn-primary">Read More</a> </div>
</div>
</div> </div>
</div> {% empty %}
<p>No blog posts available.</p>
{% endfor %}
</div> </div>
{% empty %}
<p>No blog posts available.</p>
{% endfor %}
</div> </div>
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,19 +0,0 @@
{% extends "components/base.html" %}
{% block title %}Notice Detail{% endblock %}
{% block main_area %}
<h2 class="fw-bold pt-3 pb-2">{{ notice.title }}</h2>
<hr>
<div>
{{ notice.contents|linebreaks }}
</div>
<hr>
<p class="text-muted">작성자:
{{ notice.author }}</p>
<p class="text-muted">작성일:
{{ notice.created_at|date:"Y-m-d H:i" }}</p>
<p class="text-muted">수정일:
{{ notice.updated_at|date:"Y-m-d H:i" }}</p>
<a href="{% url 'butler:notice_list' %}" class="btn btn-secondary mt-3">Back to Notices</a>
{% endblock %}

View File

@ -8,9 +8,9 @@ urlpatterns = [
# Landing Page # Landing Page
path('', views.LandingPageView.as_view(), name='landing'), # 클래스 기반 뷰(CBV) 호출 path('', views.LandingPageView.as_view(), name='landing'), # 클래스 기반 뷰(CBV) 호출
path('notice', views.notice_list, name='notice_list'), # path('notice', views.notice_list, name='notice_list'),
path('create_notice/', views.create_notice, name='create_notice'), # 포스트 작성 # path('create_notice/', views.create_notice, name='create_notice'), # 포스트 작성
path('notice/<int:pk>/', views.notice_detail_view, name='notice_detail'), # path('notice/<int:pk>/', views.notice_detail_view, name='notice_detail'),
path('ip_mgmt', views.ip_mgmt_view, name='ip_mgmt'), path('ip_mgmt', views.ip_mgmt_view, name='ip_mgmt'),
path('ip-mgmt/add/', views.add_ip_record, name='ip_mgmt_add'), # 데이터 추가 경로 path('ip-mgmt/add/', views.add_ip_record, name='ip_mgmt_add'), # 데이터 추가 경로
path('ip-mgmt/delete/', views.delete_ip_records, name='ip_mgmt_delete'), # 삭제 URL 추가 path('ip-mgmt/delete/', views.delete_ip_records, name='ip_mgmt_delete'), # 삭제 URL 추가

View File

@ -4,9 +4,9 @@ from django.views.generic import TemplateView
from pathlib import Path from pathlib import Path
import markdown import markdown
import os import os
from .models import NoticeBoard, IPManagementRecord from .models import IPManagementRecord
from blog.models import Post from blog.models import Post
from .forms import PostForm from board_notice.models import BoardNotice
from django.db.models import Q from django.db.models import Q
@ -15,39 +15,11 @@ class LandingPageView(TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# context['var'] var를 templates로 전달해서 보여지는 것.
context['blog_posts'] = Post.objects.order_by('-created_at')[:3] context['blog_posts'] = Post.objects.order_by('-created_at')[:3]
context['notices'] = NoticeBoard.objects.order_by('-created_at')[:3] context['board_notices'] = BoardNotice.objects.order_by('-created_at')[:3]
return context return context
# --- notice ---
@login_required
def create_notice(request):
if request.method == 'POST':
form = PostForm(request.POST)
if form.is_valid():
post = form.save(commit=False)
post.author = request.user # 작성자 정보 추가
post.save()
form.save_m2m()
return redirect('butler:notice_list')
else:
form = PostForm()
return render(request, 'butler/create_notice.html', {'form': form})
def notice_list(request):
records = NoticeBoard.objects.all()
return render(request, "butler/notice_list.html", {"records": records})
def notice_detail_view(request, pk):
try:
notice = NoticeBoard.objects.get(pk=pk)
except NoticeBoard.DoesNotExist:
return render(request, "404.html") # 선택적으로 에러 처리
return render(request, "butler/notice_detail.html", {"notice": notice})
# --- ip management --- # --- ip management ---
# def ip_mgmt_view(request): # def ip_mgmt_view(request):
# # records = IPManagementRecord.objects.all() # # records = IPManagementRecord.objects.all()

View File

@ -41,6 +41,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'taggit', 'taggit',
# Custom by.icurfer # Custom by.icurfer
'board_notice',
'custom_auth', 'custom_auth',
'butler', 'butler',
'blog', 'blog',
@ -50,6 +51,7 @@ INSTALLED_APPS = [
'nhnc_mgmt', 'nhnc_mgmt',
'mm_msg', 'mm_msg',
'ansible_manager', 'ansible_manager',
'obs_minio',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -166,6 +168,7 @@ LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/' LOGOUT_REDIRECT_URL = '/'
# MinIO 설정 # MinIO 설정
# MINIO_STORAGE_MEDIA_URL = os.environ.get('MINIO_STORAGE_MEDIA_URL', '') MINIO_ENDPOINT_URL = os.environ.get('MINIO_ENDPOINT_URL', '')
# MINIO_STORAGE_ACCESS_KEY = os.environ.get('MINIO_STORAGE_ACCESS_KEY', '') MINIO_ACCESS_KEY = os.environ.get('MINIO_ACCESS_KEY', '')
# MINIO_STORAGE_SECRET_KEY = os.environ.get('MINIO_STORAGE_SECRET_KEY', '') MINIO_SECRET_KEY = os.environ.get('MINIO_SECRET_KEY', '')
MINIO_DEFAULT_BUCKET=os.environ.get('MINIO_DEFAULT_BUCKET', '')

View File

@ -24,11 +24,13 @@ urlpatterns = [
path('', include('butler.urls')), path('', include('butler.urls')),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('accounts/', include('custom_auth.urls')), # custom_auth URL 추가 path('accounts/', include('custom_auth.urls')), # custom_auth URL 추가
path('notice/', include('board_notice.urls')),
path('blog/', include('blog.urls')),
path('nhncloud/', include('nhncloud.urls')), path('nhncloud/', include('nhncloud.urls')),
path('nhnc_mgmt/', include('nhnc_mgmt.urls')), path('nhnc_mgmt/', include('nhnc_mgmt.urls')),
path('mm_msg/', include('mm_msg.urls')), path('mm_msg/', include('mm_msg.urls')),
path('blog/', include('blog.urls')),
path('ansible_manager/', include('ansible_manager.urls')), path('ansible_manager/', include('ansible_manager.urls')),
path('obs_minio/', include('obs_minio.urls')),
] ]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

0
obs_minio/__init__.py Normal file
View File

3
obs_minio/admin.py Normal file
View File

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

6
obs_minio/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ObsMinioConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'obs_minio'

View File

3
obs_minio/models.py Normal file
View File

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

3
obs_minio/tests.py Normal file
View File

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

9
obs_minio/urls.py Normal file
View File

@ -0,0 +1,9 @@
from django.urls import path
from . import views
app_name = 'obs_minio'
urlpatterns = [
path('upload/', views.upload_image, name='upload_image'),
path('get_presigned_url/', views.get_presigned_url, name='get_presigned_url'),
]

76
obs_minio/views.py Normal file
View File

@ -0,0 +1,76 @@
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from minio import Minio
from minio.error import S3Error
import json
import uuid
import urllib3
# MinIO 설정
MINIO_ENDPOINT = settings.MINIO_ENDPOINT_URL
MINIO_ACCESS_KEY = settings.MINIO_ACCESS_KEY
MINIO_SECRET_KEY = settings.MINIO_SECRET_KEY
BUCKET_NAME = settings.MINIO_DEFAULT_BUCKET
# MinIO 클라이언트 생성
client = Minio(
MINIO_ENDPOINT,
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=True,
http_client=urllib3.PoolManager(cert_reqs='CERT_NONE') # SSL 검증 비활성화
)
@csrf_exempt
def upload_image(request):
"""
이미지 업로드 및 URL 반환
"""
if request.method == 'POST' and 'image' in request.FILES:
image = request.FILES['image']
unique_filename = f"uploads/{uuid.uuid4()}_{image.name}"
try:
# MinIO에 파일 업로드
client.put_object(
bucket_name=BUCKET_NAME,
object_name=unique_filename,
data=image,
length=image.size,
content_type=image.content_type,
)
# 전체 URL 생성
full_url = f"https://{MINIO_ENDPOINT}/{BUCKET_NAME}/{unique_filename}"
return JsonResponse({"url": full_url}, status=201)
except S3Error as e:
return JsonResponse({"error": str(e)}, status=500)
return JsonResponse({"error": "Invalid request"}, status=400)
@csrf_exempt
def get_presigned_url(request):
"""
Presigned URL 생성 및 반환
"""
if request.method == 'POST':
try:
data = json.loads(request.body)
object_name = data.get('object_name')
if not object_name:
return JsonResponse({"error": "Missing object_name"}, status=400)
# Presigned URL 생성
presigned_url = client.presigned_get_object(BUCKET_NAME, object_name)
return JsonResponse({"presigned_url": presigned_url}, status=200)
except S3Error as e:
return JsonResponse({"error": str(e)}, status=500)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
return JsonResponse({"error": "Invalid request method"}, status=405)

View File

@ -1 +1 @@
dev_0.0.24 dev_0.0.26