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:
Hyungi Ahn
2026-03-04 13:50:35 +09:00
parent 39b2139ea1
commit 87af06ca9c
4 changed files with 142 additions and 34 deletions

View File

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

View File

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

View File

@@ -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;
} }
# 업로드 파일 # 업로드 파일

View File

@@ -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>일일보고서 생성';