fix: 일일보고서 권한 체크를 페이지 권한 기반으로 변경 및 Edge 호환성 개선
- reports.py: role==admin 하드코딩 → check_page_access로 변경하여 reports/reports_daily 페이지 권한 보유자도 미리보기/엑셀 내보내기 가능 - page_permissions.py: 동기 헬퍼 함수 check_page_access() 추가 (기존 async API 엔드포인트를 await 없이 호출하는 버그 해결) - reports-daily.html: 에러 핸들링 강화 (401/403 구분), blob download revokeObjectURL 지연 처리 (Edge 호환) - nginx.conf: proxy_read_timeout/proxy_buffering off 추가 - reports.py: JSONResponse+jsonable_encoder로 명시적 직렬화, Content-Disposition에 ASCII 폴백 파일명 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,9 +50,36 @@ DEFAULT_PAGES = {
|
|||||||
'issues_management': {'title': '관리함', 'default_access': False},
|
'issues_management': {'title': '관리함', 'default_access': False},
|
||||||
'issues_archive': {'title': '폐기함', 'default_access': False},
|
'issues_archive': {'title': '폐기함', 'default_access': False},
|
||||||
'issues_dashboard': {'title': '현황판', 'default_access': True},
|
'issues_dashboard': {'title': '현황판', 'default_access': True},
|
||||||
'reports': {'title': '보고서', 'default_access': False}
|
'projects_manage': {'title': '프로젝트 관리', 'default_access': False},
|
||||||
|
'daily_work': {'title': '일일 공수', 'default_access': False},
|
||||||
|
'reports': {'title': '보고서', 'default_access': False},
|
||||||
|
'reports_daily': {'title': '일일보고서', 'default_access': False},
|
||||||
|
'users_manage': {'title': '사용자 관리', 'default_access': False}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_page_access(user_id: int, page_name: str, db: Session) -> bool:
|
||||||
|
"""동기 헬퍼: 사용자의 페이지 접근 권한 확인 (라우터 내부 호출용)"""
|
||||||
|
# admin은 모든 페이지 접근 가능
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if user and user.role == UserRole.admin:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 유효하지 않은 페이지
|
||||||
|
if page_name not in DEFAULT_PAGES:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 개별 권한 확인
|
||||||
|
permission = db.query(UserPagePermission).filter(
|
||||||
|
UserPagePermission.user_id == user_id,
|
||||||
|
UserPagePermission.page_name == page_name
|
||||||
|
).first()
|
||||||
|
if permission:
|
||||||
|
return permission.can_access
|
||||||
|
|
||||||
|
# 기본 권한
|
||||||
|
return DEFAULT_PAGES[page_name]['default_access']
|
||||||
|
|
||||||
@router.post("/page-permissions/grant")
|
@router.post("/page-permissions/grant")
|
||||||
async def grant_page_permission(
|
async def grant_page_permission(
|
||||||
request: PagePermissionRequest,
|
request: PagePermissionRequest,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, and_, or_
|
from sqlalchemy import func, and_, or_
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
@@ -13,9 +14,10 @@ from openpyxl.drawing.image import Image as XLImage
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from database.database import get_db
|
from database.database import get_db
|
||||||
from database.models import Issue, IssueStatus, IssueCategory, User, UserRole, ReviewStatus
|
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole, ReviewStatus
|
||||||
from database import schemas
|
from database import schemas
|
||||||
from routers.auth import get_current_user
|
from routers.auth import get_current_user
|
||||||
|
from routers.page_permissions import check_page_access
|
||||||
from utils.tkuser_client import get_token_from_request
|
from utils.tkuser_client import get_token_from_request
|
||||||
import utils.tkuser_client as tkuser_client
|
import utils.tkuser_client as tkuser_client
|
||||||
|
|
||||||
@@ -30,9 +32,14 @@ async def generate_report_summary(
|
|||||||
"""보고서 요약 생성"""
|
"""보고서 요약 생성"""
|
||||||
start_date = report_request.start_date
|
start_date = report_request.start_date
|
||||||
end_date = report_request.end_date
|
end_date = report_request.end_date
|
||||||
|
|
||||||
total_hours = 0
|
# 일일 공수 합계
|
||||||
|
daily_works = db.query(DailyWork).filter(
|
||||||
|
DailyWork.date >= start_date.date(),
|
||||||
|
DailyWork.date <= end_date.date()
|
||||||
|
).all()
|
||||||
|
total_hours = sum(w.total_hours for w in daily_works)
|
||||||
|
|
||||||
# 이슈 통계
|
# 이슈 통계
|
||||||
issues_query = db.query(Issue).filter(
|
issues_query = db.query(Issue).filter(
|
||||||
Issue.report_date >= start_date,
|
Issue.report_date >= start_date,
|
||||||
@@ -111,6 +118,29 @@ async def get_report_issues(
|
|||||||
"detail_notes": issue.detail_notes
|
"detail_notes": issue.detail_notes
|
||||||
} for issue in issues]
|
} for issue in issues]
|
||||||
|
|
||||||
|
@router.get("/daily-works")
|
||||||
|
async def get_report_daily_works(
|
||||||
|
start_date: datetime,
|
||||||
|
end_date: datetime,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""보고서용 일일 공수 목록"""
|
||||||
|
works = db.query(DailyWork).filter(
|
||||||
|
DailyWork.date >= start_date.date(),
|
||||||
|
DailyWork.date <= end_date.date()
|
||||||
|
).order_by(DailyWork.date).all()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"date": work.date,
|
||||||
|
"worker_count": work.worker_count,
|
||||||
|
"regular_hours": work.regular_hours,
|
||||||
|
"overtime_workers": work.overtime_workers,
|
||||||
|
"overtime_hours": work.overtime_hours,
|
||||||
|
"overtime_total": work.overtime_total,
|
||||||
|
"total_hours": work.total_hours
|
||||||
|
} for work in works]
|
||||||
|
|
||||||
@router.get("/daily-preview")
|
@router.get("/daily-preview")
|
||||||
async def preview_daily_report(
|
async def preview_daily_report(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
@@ -120,9 +150,9 @@ async def preview_daily_report(
|
|||||||
):
|
):
|
||||||
"""일일보고서 미리보기 - 추출될 항목 목록"""
|
"""일일보고서 미리보기 - 추출될 항목 목록"""
|
||||||
|
|
||||||
# 권한 확인
|
# 권한 확인: admin 또는 reports/reports_daily 페이지 권한 보유자
|
||||||
if current_user.role != UserRole.admin:
|
if current_user.role != UserRole.admin and not check_page_access(current_user.id, 'reports', db) and not check_page_access(current_user.id, 'reports_daily', db):
|
||||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
raise HTTPException(status_code=403, detail="보고서 접근 권한이 없습니다")
|
||||||
|
|
||||||
# 프로젝트 확인 (tkuser API)
|
# 프로젝트 확인 (tkuser API)
|
||||||
token = get_token_from_request(request)
|
token = get_token_from_request(request)
|
||||||
@@ -150,15 +180,15 @@ async def preview_daily_report(
|
|||||||
# 통계 계산
|
# 통계 계산
|
||||||
stats = calculate_project_stats(issues)
|
stats = calculate_project_stats(issues)
|
||||||
|
|
||||||
# 이슈 리스트를 schema로 변환
|
# 이슈 리스트를 schema로 변환 후 명시적 직렬화 (브라우저 호환성)
|
||||||
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
|
issues_data = [schemas.Issue.from_orm(issue) for issue in issues]
|
||||||
|
|
||||||
return {
|
return JSONResponse(content=jsonable_encoder({
|
||||||
"project": project,
|
"project": project,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"issues": issues_data,
|
"issues": issues_data,
|
||||||
"total_issues": len(issues)
|
"total_issues": len(issues)
|
||||||
}
|
}))
|
||||||
|
|
||||||
@router.post("/daily-export")
|
@router.post("/daily-export")
|
||||||
async def export_daily_report(
|
async def export_daily_report(
|
||||||
@@ -167,11 +197,11 @@ async def export_daily_report(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""품질팀용 일일보고서 엑셀 내보내기"""
|
"""일일보고서 엑셀 내보내기"""
|
||||||
|
|
||||||
# 권한 확인 (품질팀만 접근 가능)
|
# 권한 확인: admin 또는 reports/reports_daily 페이지 권한 보유자
|
||||||
if current_user.role != UserRole.admin:
|
if current_user.role != UserRole.admin and not check_page_access(current_user.id, 'reports', db) and not check_page_access(current_user.id, 'reports_daily', db):
|
||||||
raise HTTPException(status_code=403, detail="품질팀만 접근 가능합니다")
|
raise HTTPException(status_code=403, detail="보고서 접근 권한이 없습니다")
|
||||||
|
|
||||||
# 프로젝트 확인 (tkuser API)
|
# 프로젝트 확인 (tkuser API)
|
||||||
token = get_token_from_request(request)
|
token = get_token_from_request(request)
|
||||||
@@ -705,15 +735,18 @@ async def export_daily_report(
|
|||||||
today = date.today().strftime('%Y%m%d')
|
today = date.today().strftime('%Y%m%d')
|
||||||
filename = f"{project['project_name']}_일일보고서_{today}.xlsx"
|
filename = f"{project['project_name']}_일일보고서_{today}.xlsx"
|
||||||
|
|
||||||
# 한글 파일명을 위한 URL 인코딩
|
# 한글 파일명을 위한 URL 인코딩 (ASCII 폴백 + UTF-8 인코딩 둘 다 제공)
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
encoded_filename = quote(filename.encode('utf-8'))
|
encoded_filename = quote(filename.encode('utf-8'))
|
||||||
|
ascii_filename = f"daily_report_{today}.xlsx"
|
||||||
|
|
||||||
|
excel_data = excel_buffer.read()
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
io.BytesIO(excel_buffer.read()),
|
io.BytesIO(excel_data),
|
||||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
|
"Content-Disposition": f"attachment; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}",
|
||||||
|
"Content-Length": str(len(excel_data)),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
|
# 엑셀 생성 등 시간이 걸리는 API 대응
|
||||||
|
proxy_read_timeout 120s;
|
||||||
|
proxy_send_timeout 120s;
|
||||||
|
proxy_buffering off;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 업로드 파일
|
# 업로드 파일
|
||||||
|
|||||||
@@ -11,17 +11,22 @@
|
|||||||
<!-- Font Awesome -->
|
<!-- Font Awesome -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
<!-- 공통 스타일 -->
|
|
||||||
<link rel="stylesheet" href="/static/css/tkqc-common.css">
|
|
||||||
|
|
||||||
<!-- Custom Styles -->
|
<!-- Custom Styles -->
|
||||||
<style>
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
.report-card {
|
.report-card {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
border-left: 4px solid transparent;
|
border-left: 4px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.report-card:hover {
|
.report-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||||
border-left-color: #10b981;
|
border-left-color: #10b981;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +34,10 @@
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-card:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.issue-row {
|
.issue-row {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -38,11 +47,11 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="bg-gray-50 min-h-screen">
|
||||||
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
<!-- 공통 헤더가 여기에 자동으로 삽입됩니다 -->
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="container mx-auto px-4 py-8" style="padding-top: 72px;">
|
<main class="container mx-auto px-4 py-8" style="padding-top: 80px;">
|
||||||
<!-- 페이지 헤더 -->
|
<!-- 페이지 헤더 -->
|
||||||
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
@@ -294,9 +303,16 @@
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const apiUrl = window.API_BASE_URL || '/api';
|
const apiUrl = window.API_BASE_URL || '/api';
|
||||||
|
const token = TokenManager.getToken();
|
||||||
|
if (!token) {
|
||||||
|
alert('인증 토큰이 없습니다. 다시 로그인해주세요.');
|
||||||
|
window.location.href = window.authManager ? window.authManager._getLoginUrl() : '/login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${apiUrl}/reports/daily-preview?project_id=${selectedProjectId}`, {
|
const response = await fetch(`${apiUrl}/reports/daily-preview?project_id=${selectedProjectId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
'Authorization': `Bearer ${token}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -304,11 +320,20 @@
|
|||||||
previewData = await response.json();
|
previewData = await response.json();
|
||||||
displayPreview(previewData);
|
displayPreview(previewData);
|
||||||
} else {
|
} else {
|
||||||
alert('미리보기 로드에 실패했습니다.');
|
const errorText = await response.text().catch(() => '');
|
||||||
|
console.error('미리보기 로드 실패:', response.status, errorText);
|
||||||
|
if (response.status === 401) {
|
||||||
|
alert('인증이 만료되었습니다. 다시 로그인해주세요.');
|
||||||
|
window.location.href = window.authManager ? window.authManager._getLoginUrl() : '/login.html';
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
alert('권한이 없습니다. 품질팀 계정으로 로그인해주세요.');
|
||||||
|
} else {
|
||||||
|
alert(`미리보기 로드에 실패했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('미리보기 로드 오류:', error);
|
console.error('미리보기 로드 오류:', error);
|
||||||
alert('미리보기 로드 중 오류가 발생했습니다.');
|
alert('미리보기 로드 중 오류가 발생했습니다: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,11 +444,18 @@
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|
||||||
const apiUrl = window.API_BASE_URL || '/api';
|
const apiUrl = window.API_BASE_URL || '/api';
|
||||||
|
const token = TokenManager.getToken();
|
||||||
|
if (!token) {
|
||||||
|
alert('인증 토큰이 없습니다. 다시 로그인해주세요.');
|
||||||
|
window.location.href = window.authManager ? window.authManager._getLoginUrl() : '/login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${apiUrl}/reports/daily-export`, {
|
const response = await fetch(`${apiUrl}/reports/daily-export`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${TokenManager.getToken()}`
|
'Authorization': `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
project_id: parseInt(selectedProjectId)
|
project_id: parseInt(selectedProjectId)
|
||||||
@@ -444,8 +476,12 @@
|
|||||||
|
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
// Edge 호환: revokeObjectURL을 지연시켜 다운로드가 시작될 시간 확보
|
||||||
|
setTimeout(() => {
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
// 성공 메시지
|
// 성공 메시지
|
||||||
showSuccessMessage('일일보고서가 성공적으로 생성되었습니다!');
|
showSuccessMessage('일일보고서가 성공적으로 생성되었습니다!');
|
||||||
@@ -456,13 +492,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
const error = await response.text();
|
const errorText = await response.text().catch(() => '');
|
||||||
console.error('보고서 생성 실패:', error);
|
console.error('보고서 생성 실패:', response.status, errorText);
|
||||||
alert('보고서 생성에 실패했습니다. 다시 시도해주세요.');
|
if (response.status === 401) {
|
||||||
|
alert('인증이 만료되었습니다. 다시 로그인해주세요.');
|
||||||
|
window.location.href = window.authManager ? window.authManager._getLoginUrl() : '/login.html';
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
alert('권한이 없습니다. 품질팀 계정으로 로그인해주세요.');
|
||||||
|
} else {
|
||||||
|
alert(`보고서 생성에 실패했습니다. (${response.status})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('보고서 생성 오류:', error);
|
console.error('보고서 생성 오류:', error);
|
||||||
alert('보고서 생성 중 오류가 발생했습니다.');
|
alert('보고서 생성 중 오류가 발생했습니다: ' + error.message);
|
||||||
} finally {
|
} finally {
|
||||||
const button = document.getElementById('generateReportBtn');
|
const button = document.getElementById('generateReportBtn');
|
||||||
button.innerHTML = '<i class="fas fa-download mr-2"></i>일일보고서 생성';
|
button.innerHTML = '<i class="fas fa-download mr-2"></i>일일보고서 생성';
|
||||||
|
|||||||
Reference in New Issue
Block a user