propongo 1.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +1 -0
- app/config.py +43 -0
- app/export.py +484 -0
- app/main.py +1041 -0
- app/models.py +201 -0
- app/snippets/deliverables.json +32 -0
- app/snippets/organization.json +26 -0
- app/snippets.py +156 -0
- app/static/css/style.css +1359 -0
- app/static/js/app.js +712 -0
- app/static/js/budget.js +352 -0
- app/static/js/gantt.js +519 -0
- app/static/js/snippets.js +181 -0
- app/static/js/tracker.js +152 -0
- app/templates/base.html +171 -0
- app/templates/budget.html +113 -0
- app/templates/custom_sections.html +105 -0
- app/templates/export_proposal.html +354 -0
- app/templates/export_tracker.html +254 -0
- app/templates/index.html +53 -0
- app/templates/preview.html +178 -0
- app/templates/qualifications.html +17 -0
- app/templates/scope.html +85 -0
- app/templates/templates.html +56 -0
- app/templates/timeline.html +190 -0
- app/templates/tracker.html +226 -0
- app/utils.py +257 -0
- propongo-1.3.4.dist-info/METADATA +240 -0
- propongo-1.3.4.dist-info/RECORD +32 -0
- propongo-1.3.4.dist-info/WHEEL +5 -0
- propongo-1.3.4.dist-info/entry_points.txt +2 -0
- propongo-1.3.4.dist-info/top_level.txt +1 -0
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.4"
|
app/config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Configuration settings for Propongo."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Config:
|
|
8
|
+
"""Application configuration."""
|
|
9
|
+
|
|
10
|
+
# Server settings
|
|
11
|
+
HOST = os.environ.get('HOST', '0.0.0.0')
|
|
12
|
+
PORT = int(os.environ.get('PORT', 5000))
|
|
13
|
+
DEBUG = os.environ.get('DEBUG', 'True').lower() in ('true', '1', 'yes')
|
|
14
|
+
|
|
15
|
+
# Security
|
|
16
|
+
SECRET_KEY = os.environ.get('FLASK_SECRET_KEY', os.urandom(24).hex())
|
|
17
|
+
|
|
18
|
+
# File paths
|
|
19
|
+
if sys.platform == "win32":
|
|
20
|
+
DATA_DIR = os.path.join(os.path.expanduser("~"), "Documents", "Propongo")
|
|
21
|
+
else:
|
|
22
|
+
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data')
|
|
23
|
+
PROPOSALS_DIR = os.path.join(DATA_DIR, 'proposals')
|
|
24
|
+
EXPORTS_DIR = os.path.join(DATA_DIR, 'exports')
|
|
25
|
+
|
|
26
|
+
# Logging
|
|
27
|
+
LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Error messages
|
|
31
|
+
ERROR_MESSAGES = {
|
|
32
|
+
'PROPOSAL_NOT_FOUND': {'error': 'Proposal not found'},
|
|
33
|
+
'SECTION_NOT_FOUND': {'error': 'Section not found'},
|
|
34
|
+
'TASK_NOT_FOUND': {'error': 'Task not found'},
|
|
35
|
+
'BUDGET_ITEM_NOT_FOUND': {'error': 'Budget item not found'},
|
|
36
|
+
'NO_DATA': {'error': 'No data provided'},
|
|
37
|
+
'NO_FILE': {'error': 'No file provided'},
|
|
38
|
+
'INVALID_FILE_TYPE': {'error': 'Invalid file type'},
|
|
39
|
+
'INVALID_NUMERIC': {'error': 'Invalid numeric value'},
|
|
40
|
+
'EXCEL_NOT_INSTALLED': {'error': 'Excel support not installed. Install pandas and openpyxl.'},
|
|
41
|
+
'EXCEL_INVALID_FILE': {'error': 'Invalid Excel file'},
|
|
42
|
+
'EXCEL_PROCESSING_ERROR': {'error': 'Failed to process Excel file'},
|
|
43
|
+
}
|
app/export.py
ADDED
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""Export functionality for PDF, HTML, and DOCX generation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
import re as _re
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from typing import Tuple
|
|
8
|
+
try:
|
|
9
|
+
from weasyprint import HTML
|
|
10
|
+
except (OSError, ImportError):
|
|
11
|
+
HTML = None
|
|
12
|
+
|
|
13
|
+
GTK3_MISSING_MSG = (
|
|
14
|
+
"PDF export requires GTK3. Download the installer: "
|
|
15
|
+
"https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/"
|
|
16
|
+
"releases/download/2022-01-04/gtk3-runtime-3.24.31-2022-01-04-ts-win64.exe"
|
|
17
|
+
)
|
|
18
|
+
from flask import Blueprint, render_template, request, send_file, jsonify, Response
|
|
19
|
+
from markupsafe import Markup
|
|
20
|
+
from .models import Proposal
|
|
21
|
+
from .utils import build_export_context, build_tracker_export_context
|
|
22
|
+
from .config import Config, ERROR_MESSAGES
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
export_bp = Blueprint("export", __name__)
|
|
27
|
+
|
|
28
|
+
EXPORT_DIR = Config.EXPORTS_DIR
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def ensure_export_dir() -> None:
|
|
32
|
+
"""Ensure export directory exists."""
|
|
33
|
+
os.makedirs(EXPORT_DIR, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _md_to_plain(text: str) -> str:
|
|
37
|
+
"""Strip markdown to plain text for DOCX."""
|
|
38
|
+
if not text:
|
|
39
|
+
return ""
|
|
40
|
+
text = _re.sub(r'#{1,6}\s+', '', text)
|
|
41
|
+
text = _re.sub(r'\*\*(.+?)\*\*', r'\1', text)
|
|
42
|
+
text = _re.sub(r'\*(.+?)\*', r'\1', text)
|
|
43
|
+
text = _re.sub(r'`(.+?)`', r'\1', text)
|
|
44
|
+
text = _re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
|
|
45
|
+
text = _re.sub(r'^[-*]\s+', ' - ', text, flags=_re.MULTILINE)
|
|
46
|
+
return text.strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _build_proposal_docx(proposal) -> BytesIO:
|
|
50
|
+
"""Build a DOCX document for a proposal."""
|
|
51
|
+
from docx import Document
|
|
52
|
+
from docx.shared import Inches, Pt, Cm, RGBColor
|
|
53
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
54
|
+
from docx.enum.table import WD_TABLE_ALIGNMENT
|
|
55
|
+
|
|
56
|
+
doc = Document()
|
|
57
|
+
|
|
58
|
+
style = doc.styles['Normal']
|
|
59
|
+
style.font.name = 'Calibri'
|
|
60
|
+
style.font.size = Pt(11)
|
|
61
|
+
style.paragraph_format.space_after = Pt(6)
|
|
62
|
+
style.paragraph_format.line_spacing = 1.15
|
|
63
|
+
|
|
64
|
+
for section in doc.sections:
|
|
65
|
+
section.top_margin = Cm(2.5)
|
|
66
|
+
section.bottom_margin = Cm(2.5)
|
|
67
|
+
section.left_margin = Cm(2.5)
|
|
68
|
+
section.right_margin = Cm(2.5)
|
|
69
|
+
|
|
70
|
+
p = doc.add_paragraph()
|
|
71
|
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
72
|
+
run = p.add_run(proposal.title or "Proposal")
|
|
73
|
+
run.bold = True
|
|
74
|
+
run.font.size = Pt(22)
|
|
75
|
+
run.font.color.rgb = RGBColor(0x1e, 0x3a, 0x5f)
|
|
76
|
+
|
|
77
|
+
if proposal.client_name:
|
|
78
|
+
p = doc.add_paragraph()
|
|
79
|
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
80
|
+
run = p.add_run(f"Funder: {proposal.client_name}")
|
|
81
|
+
run.font.size = Pt(12)
|
|
82
|
+
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
|
|
83
|
+
|
|
84
|
+
if proposal.subtitle:
|
|
85
|
+
p = doc.add_paragraph()
|
|
86
|
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
87
|
+
run = p.add_run(f"Program: {proposal.subtitle}")
|
|
88
|
+
run.font.size = Pt(12)
|
|
89
|
+
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
|
|
90
|
+
|
|
91
|
+
p = doc.add_paragraph()
|
|
92
|
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
93
|
+
run = p.add_run(proposal.updated_at[:10] if proposal.updated_at else "")
|
|
94
|
+
run.font.size = Pt(10)
|
|
95
|
+
run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
|
|
96
|
+
|
|
97
|
+
doc.add_paragraph()
|
|
98
|
+
|
|
99
|
+
if proposal.project_summary:
|
|
100
|
+
doc.add_heading('Project Summary', level=1)
|
|
101
|
+
for line in _md_to_plain(proposal.project_summary).split('\n'):
|
|
102
|
+
if line.strip():
|
|
103
|
+
doc.add_paragraph(line.strip())
|
|
104
|
+
|
|
105
|
+
if proposal.scope:
|
|
106
|
+
doc.add_heading('Scope', level=1)
|
|
107
|
+
for line in _md_to_plain(proposal.scope).split('\n'):
|
|
108
|
+
if line.strip():
|
|
109
|
+
doc.add_paragraph(line.strip())
|
|
110
|
+
|
|
111
|
+
if proposal.budget_items:
|
|
112
|
+
doc.add_heading('Budget', level=1)
|
|
113
|
+
table = doc.add_table(rows=1, cols=4)
|
|
114
|
+
table.style = 'Light Grid Accent 1'
|
|
115
|
+
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
116
|
+
headers = ['Item', 'Cost/Unit', 'Units', 'Total']
|
|
117
|
+
for i, h in enumerate(headers):
|
|
118
|
+
cell = table.rows[0].cells[i]
|
|
119
|
+
cell.text = h
|
|
120
|
+
for paragraph in cell.paragraphs:
|
|
121
|
+
for run in paragraph.runs:
|
|
122
|
+
run.bold = True
|
|
123
|
+
run.font.size = Pt(10)
|
|
124
|
+
|
|
125
|
+
for task in proposal.tasks:
|
|
126
|
+
task_items = [b for b in proposal.budget_items if b.get("task_id") == task["id"]]
|
|
127
|
+
if not task_items:
|
|
128
|
+
continue
|
|
129
|
+
task_total = sum(i.get("cost_per_unit", 0) * i.get("units", 0) for i in task_items)
|
|
130
|
+
row = table.add_row()
|
|
131
|
+
row.cells[0].text = task["name"]
|
|
132
|
+
row.cells[3].text = f"{task_total:,.0f}"
|
|
133
|
+
for cell in row.cells:
|
|
134
|
+
for paragraph in cell.paragraphs:
|
|
135
|
+
for run in paragraph.runs:
|
|
136
|
+
run.bold = True
|
|
137
|
+
run.font.size = Pt(10)
|
|
138
|
+
for item in task_items:
|
|
139
|
+
total = item.get("cost_per_unit", 0) * item.get("units", 0)
|
|
140
|
+
row = table.add_row()
|
|
141
|
+
row.cells[0].text = f" {item['name']}"
|
|
142
|
+
row.cells[1].text = f"{item.get('cost_per_unit', 0):,.0f}"
|
|
143
|
+
row.cells[2].text = f"{int(item.get('units', 0))}"
|
|
144
|
+
row.cells[3].text = f"{total:,.0f}"
|
|
145
|
+
for cell in row.cells:
|
|
146
|
+
for paragraph in cell.paragraphs:
|
|
147
|
+
for run in paragraph.runs:
|
|
148
|
+
run.font.size = Pt(10)
|
|
149
|
+
|
|
150
|
+
row = table.add_row()
|
|
151
|
+
row.cells[0].text = 'Subtotal'
|
|
152
|
+
row.cells[3].text = f"{proposal.total_budget:,.0f}"
|
|
153
|
+
for cell in row.cells:
|
|
154
|
+
for paragraph in cell.paragraphs:
|
|
155
|
+
for run in paragraph.runs:
|
|
156
|
+
run.bold = True
|
|
157
|
+
|
|
158
|
+
indirect_percent = getattr(proposal, 'indirect_percent', 0) or 0
|
|
159
|
+
if indirect_percent > 0:
|
|
160
|
+
indirect_amount = proposal.total_budget * (indirect_percent / 100)
|
|
161
|
+
row = table.add_row()
|
|
162
|
+
row.cells[0].text = f"Indirect ({indirect_percent:.0f}%)"
|
|
163
|
+
row.cells[3].text = f"{indirect_amount:,.0f}"
|
|
164
|
+
|
|
165
|
+
row = table.add_row()
|
|
166
|
+
row.cells[0].text = 'Total'
|
|
167
|
+
indirect_amount = proposal.total_budget * (indirect_percent / 100)
|
|
168
|
+
row.cells[3].text = f"${proposal.total_budget + indirect_amount:,.0f}"
|
|
169
|
+
for cell in row.cells:
|
|
170
|
+
for paragraph in cell.paragraphs:
|
|
171
|
+
for run in paragraph.runs:
|
|
172
|
+
run.bold = True
|
|
173
|
+
|
|
174
|
+
if proposal.show_budget_description and proposal.budget_description:
|
|
175
|
+
doc.add_heading('Budget Description', level=1)
|
|
176
|
+
for line in _md_to_plain(proposal.budget_description).split('\n'):
|
|
177
|
+
if line.strip():
|
|
178
|
+
doc.add_paragraph(line.strip())
|
|
179
|
+
|
|
180
|
+
if proposal.qualifications:
|
|
181
|
+
doc.add_heading('Qualifications', level=1)
|
|
182
|
+
for line in _md_to_plain(proposal.qualifications).split('\n'):
|
|
183
|
+
if line.strip():
|
|
184
|
+
doc.add_paragraph(line.strip())
|
|
185
|
+
|
|
186
|
+
if proposal.custom_sections:
|
|
187
|
+
for section in sorted(proposal.custom_sections, key=lambda s: s.get("order", 0)):
|
|
188
|
+
doc.add_heading(section.get("title", "Section"), level=1)
|
|
189
|
+
for line in _md_to_plain(section.get("content", "")).split('\n'):
|
|
190
|
+
if line.strip():
|
|
191
|
+
doc.add_paragraph(line.strip())
|
|
192
|
+
|
|
193
|
+
buf = BytesIO()
|
|
194
|
+
doc.save(buf)
|
|
195
|
+
buf.seek(0)
|
|
196
|
+
return buf
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _build_tracker_docx(proposal, ctx) -> BytesIO:
|
|
200
|
+
"""Build a DOCX document for the project tracker."""
|
|
201
|
+
from docx import Document
|
|
202
|
+
from docx.shared import Pt, Cm, RGBColor
|
|
203
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
204
|
+
from docx.enum.table import WD_TABLE_ALIGNMENT
|
|
205
|
+
|
|
206
|
+
doc = Document()
|
|
207
|
+
style = doc.styles['Normal']
|
|
208
|
+
style.font.name = 'Calibri'
|
|
209
|
+
style.font.size = Pt(11)
|
|
210
|
+
style.paragraph_format.space_after = Pt(4)
|
|
211
|
+
style.paragraph_format.line_spacing = 1.15
|
|
212
|
+
|
|
213
|
+
for section in doc.sections:
|
|
214
|
+
section.top_margin = Cm(2.5)
|
|
215
|
+
section.bottom_margin = Cm(2.5)
|
|
216
|
+
section.left_margin = Cm(2.5)
|
|
217
|
+
section.right_margin = Cm(2.5)
|
|
218
|
+
|
|
219
|
+
p = doc.add_paragraph()
|
|
220
|
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
221
|
+
run = p.add_run(f"Project Report: {proposal.title}")
|
|
222
|
+
run.bold = True
|
|
223
|
+
run.font.size = Pt(20)
|
|
224
|
+
run.font.color.rgb = RGBColor(0x1e, 0x3a, 0x5f)
|
|
225
|
+
|
|
226
|
+
if proposal.client_name:
|
|
227
|
+
p = doc.add_paragraph()
|
|
228
|
+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
229
|
+
run = p.add_run(f"{proposal.client_name}{(' - ' + proposal.subtitle) if proposal.subtitle else ''}")
|
|
230
|
+
run.font.size = Pt(12)
|
|
231
|
+
run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
|
|
232
|
+
|
|
233
|
+
doc.add_paragraph()
|
|
234
|
+
|
|
235
|
+
doc.add_heading('Dashboard Summary', level=1)
|
|
236
|
+
table = doc.add_table(rows=2, cols=4)
|
|
237
|
+
table.style = 'Light Grid Accent 1'
|
|
238
|
+
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
239
|
+
headers = ['Tasks Completed', 'Total Tasks', 'Total Budget', 'Milestones']
|
|
240
|
+
values = [f"{ctx['overall_pct']}%", str(len(ctx['tasks'])), f"${ctx['total_with_indirect']:,.0f}", str(len(ctx['milestones']))]
|
|
241
|
+
for i, h in enumerate(headers):
|
|
242
|
+
table.rows[0].cells[i].text = h
|
|
243
|
+
for run in table.rows[0].cells[i].paragraphs[0].runs:
|
|
244
|
+
run.bold = True
|
|
245
|
+
run.font.size = Pt(10)
|
|
246
|
+
for i, v in enumerate(values):
|
|
247
|
+
table.rows[1].cells[i].text = v
|
|
248
|
+
for run in table.rows[1].cells[i].paragraphs[0].runs:
|
|
249
|
+
run.font.size = Pt(10)
|
|
250
|
+
|
|
251
|
+
status_labels = {
|
|
252
|
+
"not_started": "Not Started",
|
|
253
|
+
"in_progress": "In Progress",
|
|
254
|
+
"completed": "Completed",
|
|
255
|
+
"delayed": "Delayed",
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
doc.add_heading('Tasks', level=1)
|
|
259
|
+
for task in ctx['tasks']:
|
|
260
|
+
doc.add_heading(task.get('name', 'Untitled'), level=2)
|
|
261
|
+
status = status_labels.get(task.get('status', 'not_started'), 'Not Started')
|
|
262
|
+
progress = task.get('progress_pct', 0)
|
|
263
|
+
p = doc.add_paragraph()
|
|
264
|
+
run = p.add_run(f"Status: {status}")
|
|
265
|
+
run.bold = True
|
|
266
|
+
p = doc.add_paragraph(f"Progress: {progress}%")
|
|
267
|
+
if task.get('actual_start'):
|
|
268
|
+
p = doc.add_paragraph(f"Actual Start: {task['actual_start']}")
|
|
269
|
+
if task.get('actual_end'):
|
|
270
|
+
p = doc.add_paragraph(f"Actual End: {task['actual_end']}")
|
|
271
|
+
if task.get('notes'):
|
|
272
|
+
doc.add_paragraph()
|
|
273
|
+
run = p.add_run("Notes:")
|
|
274
|
+
run.bold = True
|
|
275
|
+
for line in task['notes'].split('\n'):
|
|
276
|
+
if line.strip():
|
|
277
|
+
doc.add_paragraph(line.strip())
|
|
278
|
+
|
|
279
|
+
doc.add_heading('Budget Tracking', level=1)
|
|
280
|
+
table = doc.add_table(rows=1, cols=5)
|
|
281
|
+
table.style = 'Light Grid Accent 1'
|
|
282
|
+
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
283
|
+
headers = ['Task', 'Item', 'Planned', 'Actual', 'Variance']
|
|
284
|
+
for i, h in enumerate(headers):
|
|
285
|
+
table.rows[0].cells[i].text = h
|
|
286
|
+
for run in table.rows[0].cells[i].paragraphs[0].runs:
|
|
287
|
+
run.bold = True
|
|
288
|
+
run.font.size = Pt(9)
|
|
289
|
+
|
|
290
|
+
for task in ctx['tasks']:
|
|
291
|
+
tid = task.get("id", "")
|
|
292
|
+
if tid not in ctx['task_budgets']:
|
|
293
|
+
continue
|
|
294
|
+
tb = ctx['task_budgets'][tid]
|
|
295
|
+
row = table.add_row()
|
|
296
|
+
row.cells[0].text = task.get('name', '')
|
|
297
|
+
row.cells[2].text = f"${tb['subtotal']:,.0f}"
|
|
298
|
+
row.cells[3].text = f"${tb['actual_total']:,.0f}"
|
|
299
|
+
variance = tb['actual_total'] - tb['subtotal']
|
|
300
|
+
row.cells[4].text = f"${variance:,.0f}"
|
|
301
|
+
for cell in row.cells:
|
|
302
|
+
for run in cell.paragraphs[0].runs:
|
|
303
|
+
run.bold = True
|
|
304
|
+
run.font.size = Pt(9)
|
|
305
|
+
for item in tb["items"]:
|
|
306
|
+
planned = item.get("cost_per_unit", 0) * item.get("units", 0)
|
|
307
|
+
actual = item.get("actual_cost", 0)
|
|
308
|
+
v = actual - planned
|
|
309
|
+
row = table.add_row()
|
|
310
|
+
row.cells[1].text = item.get('name', '')
|
|
311
|
+
row.cells[2].text = f"${planned:,.0f}"
|
|
312
|
+
row.cells[3].text = f"${actual:,.0f}"
|
|
313
|
+
row.cells[4].text = f"${v:,.0f}"
|
|
314
|
+
for cell in row.cells:
|
|
315
|
+
for run in cell.paragraphs[0].runs:
|
|
316
|
+
run.font.size = Pt(9)
|
|
317
|
+
|
|
318
|
+
row = table.add_row()
|
|
319
|
+
row.cells[0].text = 'Total'
|
|
320
|
+
row.cells[2].text = f"${ctx['total_with_indirect']:,.0f}"
|
|
321
|
+
row.cells[3].text = f"${ctx['total_actual'] + ctx['indirect_amount']:,.0f}"
|
|
322
|
+
for cell in row.cells:
|
|
323
|
+
for run in cell.paragraphs[0].runs:
|
|
324
|
+
run.bold = True
|
|
325
|
+
run.font.size = Pt(9)
|
|
326
|
+
|
|
327
|
+
if ctx['milestones']:
|
|
328
|
+
doc.add_heading('Milestones', level=1)
|
|
329
|
+
for m in ctx['milestones']:
|
|
330
|
+
check = "[x]" if m.get("completed") else "[ ]"
|
|
331
|
+
date_str = f" ({m['date']})" if m.get('date') else ''
|
|
332
|
+
doc.add_paragraph(f"{check} {m.get('name', '')}{date_str}")
|
|
333
|
+
|
|
334
|
+
if ctx['reports']:
|
|
335
|
+
doc.add_heading('Reports', level=1)
|
|
336
|
+
for r in reversed(ctx['reports']):
|
|
337
|
+
doc.add_heading(r.get('title', 'Report'), level=2)
|
|
338
|
+
if r.get('date'):
|
|
339
|
+
p = doc.add_paragraph(f"Date: {r['date']}")
|
|
340
|
+
p.runs[0].font.color.rgb = RGBColor(0x99, 0x99, 0x99)
|
|
341
|
+
if r.get('content'):
|
|
342
|
+
for line in r['content'].split('\n'):
|
|
343
|
+
if line.strip():
|
|
344
|
+
doc.add_paragraph(line.strip())
|
|
345
|
+
|
|
346
|
+
buf = BytesIO()
|
|
347
|
+
doc.save(buf)
|
|
348
|
+
buf.seek(0)
|
|
349
|
+
return buf
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@export_bp.route("/export/pdf/<proposal_id>")
|
|
353
|
+
def export_pdf(proposal_id: str) -> Tuple[Response, int] | Response:
|
|
354
|
+
"""Export proposal as PDF."""
|
|
355
|
+
if HTML is None:
|
|
356
|
+
return jsonify({"error": GTK3_MISSING_MSG}), 500
|
|
357
|
+
|
|
358
|
+
proposal = Proposal.load(proposal_id)
|
|
359
|
+
if not proposal:
|
|
360
|
+
logger.warning(f"Proposal not found for PDF export: {proposal_id}")
|
|
361
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
362
|
+
|
|
363
|
+
ctx = build_export_context(proposal)
|
|
364
|
+
html_content = render_template("export_proposal.html", **ctx)
|
|
365
|
+
|
|
366
|
+
ensure_export_dir()
|
|
367
|
+
pdf_path = os.path.join(EXPORT_DIR, f"{proposal_id}.pdf")
|
|
368
|
+
HTML(string=html_content, base_url=request.host_url).write_pdf(pdf_path)
|
|
369
|
+
|
|
370
|
+
return send_file(
|
|
371
|
+
pdf_path,
|
|
372
|
+
mimetype="application/pdf",
|
|
373
|
+
as_attachment=True,
|
|
374
|
+
download_name=f"{proposal.title or 'proposal'}.pdf",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@export_bp.route("/export/html/<proposal_id>")
|
|
379
|
+
def export_html(proposal_id: str) -> Tuple[Response, int] | Response:
|
|
380
|
+
"""Export proposal as HTML file."""
|
|
381
|
+
proposal = Proposal.load(proposal_id)
|
|
382
|
+
if not proposal:
|
|
383
|
+
logger.warning(f"Proposal not found for HTML export: {proposal_id}")
|
|
384
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
385
|
+
|
|
386
|
+
ctx = build_export_context(proposal)
|
|
387
|
+
html_content = render_template("export_proposal.html", **ctx)
|
|
388
|
+
|
|
389
|
+
return Response(
|
|
390
|
+
html_content,
|
|
391
|
+
mimetype="text/html",
|
|
392
|
+
headers={"Content-Disposition": "inline"},
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@export_bp.route("/export/docx/<proposal_id>")
|
|
397
|
+
def export_docx(proposal_id: str) -> Tuple[Response, int] | Response:
|
|
398
|
+
"""Export proposal as DOCX file."""
|
|
399
|
+
proposal = Proposal.load(proposal_id)
|
|
400
|
+
if not proposal:
|
|
401
|
+
logger.warning(f"Proposal not found for DOCX export: {proposal_id}")
|
|
402
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
buf = _build_proposal_docx(proposal)
|
|
406
|
+
except ImportError:
|
|
407
|
+
return jsonify({"error": "python-docx not installed"}), 500
|
|
408
|
+
except Exception as e:
|
|
409
|
+
logger.error(f"DOCX export failed: {e}")
|
|
410
|
+
return jsonify({"error": f"DOCX export failed: {str(e)}"}), 500
|
|
411
|
+
|
|
412
|
+
return send_file(
|
|
413
|
+
buf,
|
|
414
|
+
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
415
|
+
as_attachment=True,
|
|
416
|
+
download_name=f"{proposal.title or 'proposal'}.docx",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@export_bp.route("/export/tracker/pdf/<proposal_id>")
|
|
421
|
+
def export_tracker_pdf(proposal_id: str) -> Tuple[Response, int] | Response:
|
|
422
|
+
"""Export tracker as PDF."""
|
|
423
|
+
if HTML is None:
|
|
424
|
+
return jsonify({"error": GTK3_MISSING_MSG}), 500
|
|
425
|
+
|
|
426
|
+
proposal = Proposal.load(proposal_id)
|
|
427
|
+
if not proposal:
|
|
428
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
429
|
+
|
|
430
|
+
ctx = build_tracker_export_context(proposal)
|
|
431
|
+
html_content = render_template("export_tracker.html", **ctx)
|
|
432
|
+
|
|
433
|
+
ensure_export_dir()
|
|
434
|
+
pdf_path = os.path.join(EXPORT_DIR, f"tracker_{proposal_id}.pdf")
|
|
435
|
+
HTML(string=html_content, base_url=request.host_url).write_pdf(pdf_path)
|
|
436
|
+
|
|
437
|
+
return send_file(
|
|
438
|
+
pdf_path,
|
|
439
|
+
mimetype="application/pdf",
|
|
440
|
+
as_attachment=True,
|
|
441
|
+
download_name=f"{proposal.title or 'project'}_tracker.pdf",
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@export_bp.route("/export/tracker/html/<proposal_id>")
|
|
446
|
+
def export_tracker_html(proposal_id: str) -> Tuple[Response, int] | Response:
|
|
447
|
+
"""Export tracker as HTML."""
|
|
448
|
+
proposal = Proposal.load(proposal_id)
|
|
449
|
+
if not proposal:
|
|
450
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
451
|
+
|
|
452
|
+
ctx = build_tracker_export_context(proposal)
|
|
453
|
+
html_content = render_template("export_tracker.html", **ctx)
|
|
454
|
+
|
|
455
|
+
return Response(
|
|
456
|
+
html_content,
|
|
457
|
+
mimetype="text/html",
|
|
458
|
+
headers={"Content-Disposition": "inline"},
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@export_bp.route("/export/tracker/docx/<proposal_id>")
|
|
463
|
+
def export_tracker_docx(proposal_id: str) -> Tuple[Response, int] | Response:
|
|
464
|
+
"""Export tracker as DOCX."""
|
|
465
|
+
proposal = Proposal.load(proposal_id)
|
|
466
|
+
if not proposal:
|
|
467
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
468
|
+
|
|
469
|
+
ctx = build_tracker_export_context(proposal)
|
|
470
|
+
|
|
471
|
+
try:
|
|
472
|
+
buf = _build_tracker_docx(proposal, ctx)
|
|
473
|
+
except ImportError:
|
|
474
|
+
return jsonify({"error": "python-docx not installed"}), 500
|
|
475
|
+
except Exception as e:
|
|
476
|
+
logger.error(f"Tracker DOCX export failed: {e}")
|
|
477
|
+
return jsonify({"error": f"DOCX export failed: {str(e)}"}), 500
|
|
478
|
+
|
|
479
|
+
return send_file(
|
|
480
|
+
buf,
|
|
481
|
+
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
482
|
+
as_attachment=True,
|
|
483
|
+
download_name=f"{proposal.title or 'project'}_tracker.docx",
|
|
484
|
+
)
|