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/main.py
ADDED
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
"""Main Flask application for Propongo."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import math
|
|
7
|
+
import uuid
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from flask import Flask, render_template, request, jsonify, redirect, url_for, Response
|
|
14
|
+
from markupsafe import Markup
|
|
15
|
+
import markdown
|
|
16
|
+
from .models import Proposal, PROPOSALS_DIR
|
|
17
|
+
from .export import export_bp
|
|
18
|
+
from .snippets import snippets_bp
|
|
19
|
+
from .utils import build_export_context
|
|
20
|
+
from .config import Config, ERROR_MESSAGES
|
|
21
|
+
from . import __version__
|
|
22
|
+
|
|
23
|
+
# Configure logging
|
|
24
|
+
logging.basicConfig(
|
|
25
|
+
level=getattr(logging, Config.LOG_LEVEL),
|
|
26
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
27
|
+
)
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_proposal_locks = {}
|
|
31
|
+
_proposal_locks_lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def markdown_to_html(text: str) -> str:
|
|
35
|
+
"""Convert Markdown text to HTML using the markdown library.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
text: Markdown formatted text
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
HTML formatted string
|
|
42
|
+
"""
|
|
43
|
+
if not text:
|
|
44
|
+
return ""
|
|
45
|
+
return markdown.markdown(
|
|
46
|
+
text,
|
|
47
|
+
extensions=['tables', 'nl2br', 'fenced_code', 'sane_lists']
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_numeric(value: Any, name: str, min_val: float = 0.0) -> float:
|
|
52
|
+
"""Validate and convert a numeric value.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
value: Value to validate
|
|
56
|
+
name: Name of the field (for error messages)
|
|
57
|
+
min_val: Minimum allowed value
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Validated float value
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If value is invalid
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
num = float(value)
|
|
67
|
+
if not math.isfinite(num):
|
|
68
|
+
raise ValueError(f"{name} must be a finite number")
|
|
69
|
+
if num < min_val:
|
|
70
|
+
raise ValueError(f"{name} must be >= {min_val}")
|
|
71
|
+
return num
|
|
72
|
+
except (ValueError, TypeError) as e:
|
|
73
|
+
raise ValueError(f"Invalid {name}: {str(e)}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def create_app() -> Flask:
|
|
77
|
+
"""Create and configure the Flask application.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Configured Flask application
|
|
81
|
+
"""
|
|
82
|
+
app = Flask(__name__)
|
|
83
|
+
app.secret_key = Config.SECRET_KEY
|
|
84
|
+
|
|
85
|
+
logger.info(f"Starting Propongo v{__version__}")
|
|
86
|
+
|
|
87
|
+
app.register_blueprint(export_bp)
|
|
88
|
+
app.register_blueprint(snippets_bp)
|
|
89
|
+
|
|
90
|
+
@app.context_processor
|
|
91
|
+
def inject_version():
|
|
92
|
+
"""Inject application version into all templates."""
|
|
93
|
+
return {"app_version": __version__}
|
|
94
|
+
|
|
95
|
+
app.jinja_env.filters["md"] = lambda text: Markup(markdown_to_html(text))
|
|
96
|
+
app.jinja_env.filters["currency"] = lambda value: f"{value:,.0f}"
|
|
97
|
+
|
|
98
|
+
@app.route("/")
|
|
99
|
+
def index():
|
|
100
|
+
"""List all proposals on the homepage."""
|
|
101
|
+
proposals = Proposal.list_all()
|
|
102
|
+
return render_template("index.html", proposals=proposals)
|
|
103
|
+
|
|
104
|
+
@app.route("/new")
|
|
105
|
+
def new_proposal():
|
|
106
|
+
"""Create a new proposal and redirect to its editor."""
|
|
107
|
+
proposal = Proposal()
|
|
108
|
+
proposal.save()
|
|
109
|
+
logger.info(f"Created new proposal: {proposal.id}")
|
|
110
|
+
return redirect(url_for("editor", proposal_id=proposal.id))
|
|
111
|
+
|
|
112
|
+
@app.route("/editor/<proposal_id>")
|
|
113
|
+
def editor(proposal_id):
|
|
114
|
+
"""Render the proposal editor page."""
|
|
115
|
+
proposal = Proposal.load(proposal_id)
|
|
116
|
+
if not proposal:
|
|
117
|
+
return redirect(url_for("index"))
|
|
118
|
+
return render_template(
|
|
119
|
+
"base.html",
|
|
120
|
+
proposal=proposal,
|
|
121
|
+
tasks=proposal.tasks,
|
|
122
|
+
budget_items=proposal.budget_items,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@app.route("/api/proposal/<proposal_id>", methods=["GET"])
|
|
126
|
+
def get_proposal(proposal_id: str) -> Tuple[Response, int]:
|
|
127
|
+
"""Return a proposal as JSON."""
|
|
128
|
+
proposal = Proposal.load(proposal_id)
|
|
129
|
+
if not proposal:
|
|
130
|
+
logger.warning(f"Proposal not found: {proposal_id}")
|
|
131
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
132
|
+
return jsonify(proposal.to_dict()), 200
|
|
133
|
+
|
|
134
|
+
@app.route("/api/proposal/<proposal_id>", methods=["PUT"])
|
|
135
|
+
def save_proposal(proposal_id: str) -> Tuple[Response, int]:
|
|
136
|
+
"""Update and save an existing proposal."""
|
|
137
|
+
data = request.get_json()
|
|
138
|
+
if not data:
|
|
139
|
+
return jsonify(ERROR_MESSAGES['NO_DATA']), 400
|
|
140
|
+
|
|
141
|
+
with _proposal_locks_lock:
|
|
142
|
+
if proposal_id not in _proposal_locks:
|
|
143
|
+
_proposal_locks[proposal_id] = threading.Lock()
|
|
144
|
+
lock = _proposal_locks[proposal_id]
|
|
145
|
+
|
|
146
|
+
with lock:
|
|
147
|
+
proposal = Proposal.load(proposal_id)
|
|
148
|
+
if not proposal:
|
|
149
|
+
logger.warning(f"Proposal not found for update: {proposal_id}")
|
|
150
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
151
|
+
|
|
152
|
+
if "tasks" in data:
|
|
153
|
+
incoming_tasks = data.pop("tasks")
|
|
154
|
+
existing_by_id = {t.get("id"): t for t in proposal.tasks}
|
|
155
|
+
merged = []
|
|
156
|
+
for t in incoming_tasks:
|
|
157
|
+
tid = t.get("id", "")
|
|
158
|
+
if tid in existing_by_id:
|
|
159
|
+
merged_task = dict(existing_by_id[tid])
|
|
160
|
+
merged_task.update(t)
|
|
161
|
+
else:
|
|
162
|
+
merged_task = t
|
|
163
|
+
merged.append(merged_task)
|
|
164
|
+
proposal.tasks = merged
|
|
165
|
+
|
|
166
|
+
skip_fields = {"id", "title", "created_at"}
|
|
167
|
+
for key, value in data.items():
|
|
168
|
+
if key in skip_fields:
|
|
169
|
+
continue
|
|
170
|
+
if hasattr(proposal, key):
|
|
171
|
+
setattr(proposal, key, value)
|
|
172
|
+
|
|
173
|
+
proposal.save()
|
|
174
|
+
return jsonify(proposal.to_dict())
|
|
175
|
+
|
|
176
|
+
@app.route("/api/proposal/<proposal_id>", methods=["DELETE"])
|
|
177
|
+
def delete_proposal(proposal_id):
|
|
178
|
+
"""Delete a proposal by ID."""
|
|
179
|
+
Proposal.delete(proposal_id)
|
|
180
|
+
return jsonify({"ok": True})
|
|
181
|
+
|
|
182
|
+
@app.route("/api/proposal/<proposal_id>/save-as", methods=["POST"])
|
|
183
|
+
def save_proposal_as(proposal_id):
|
|
184
|
+
"""Create a copy of a proposal with a new title."""
|
|
185
|
+
data = request.get_json()
|
|
186
|
+
title = data.get("title", "").strip()
|
|
187
|
+
if not title:
|
|
188
|
+
return jsonify({"error": "Title required"}), 400
|
|
189
|
+
|
|
190
|
+
proposal = Proposal.load(proposal_id)
|
|
191
|
+
if not proposal:
|
|
192
|
+
return jsonify({"error": "Not found"}), 404
|
|
193
|
+
|
|
194
|
+
new_id = re.sub(r"[^a-z0-9_-]", "_", title.lower())
|
|
195
|
+
new_id = re.sub(r"_+", "_", new_id).strip("_")
|
|
196
|
+
if not new_id:
|
|
197
|
+
new_id = uuid.uuid4().hex[:8]
|
|
198
|
+
|
|
199
|
+
original_id = new_id
|
|
200
|
+
counter = 1
|
|
201
|
+
while Proposal.load(new_id):
|
|
202
|
+
new_id = f"{original_id}_{counter}"
|
|
203
|
+
counter += 1
|
|
204
|
+
|
|
205
|
+
new_proposal = Proposal(id=new_id, title=title)
|
|
206
|
+
new_proposal.client_name = proposal.client_name
|
|
207
|
+
new_proposal.subtitle = getattr(proposal, 'subtitle', '') or ''
|
|
208
|
+
new_proposal.project_summary = proposal.project_summary
|
|
209
|
+
new_proposal.scope = getattr(proposal, 'scope', '') or ''
|
|
210
|
+
new_proposal.tasks = list(proposal.tasks)
|
|
211
|
+
new_proposal.qualifications = proposal.qualifications
|
|
212
|
+
new_proposal.budget_items = list(proposal.budget_items)
|
|
213
|
+
new_proposal.budget_item_timings = dict(proposal.budget_item_timings) if proposal.budget_item_timings else {}
|
|
214
|
+
new_proposal.start_date = proposal.start_date
|
|
215
|
+
new_proposal.indirect_percent = getattr(proposal, 'indirect_percent', 0) or 0
|
|
216
|
+
new_proposal.show_budget_description = getattr(proposal, 'show_budget_description', False)
|
|
217
|
+
new_proposal.budget_description = getattr(proposal, 'budget_description', '') or ''
|
|
218
|
+
new_proposal.custom_sections = list(proposal.custom_sections) if proposal.custom_sections else []
|
|
219
|
+
new_proposal.timeline_use_days = proposal.timeline_use_days
|
|
220
|
+
new_proposal.timeline_show_budget = proposal.timeline_show_budget
|
|
221
|
+
new_proposal.end_date = getattr(proposal, 'end_date', '') or ''
|
|
222
|
+
new_proposal.milestones = list(proposal.milestones) if proposal.milestones else []
|
|
223
|
+
new_proposal.reports = list(proposal.reports) if proposal.reports else []
|
|
224
|
+
new_proposal.save()
|
|
225
|
+
|
|
226
|
+
return jsonify({"id": new_id}), 201
|
|
227
|
+
|
|
228
|
+
@app.route("/api/proposals", methods=["GET"])
|
|
229
|
+
def list_proposals():
|
|
230
|
+
"""Return all proposals as JSON."""
|
|
231
|
+
return jsonify(Proposal.list_all())
|
|
232
|
+
|
|
233
|
+
@app.route("/templates")
|
|
234
|
+
def templates_page():
|
|
235
|
+
"""Render the templates listing page."""
|
|
236
|
+
templates = Proposal.list_templates()
|
|
237
|
+
return render_template("templates.html", templates=templates)
|
|
238
|
+
|
|
239
|
+
@app.route("/api/templates", methods=["GET"])
|
|
240
|
+
def list_templates():
|
|
241
|
+
"""Return all templates as JSON."""
|
|
242
|
+
return jsonify(Proposal.list_templates())
|
|
243
|
+
|
|
244
|
+
@app.route("/api/template/<template_id>", methods=["DELETE"])
|
|
245
|
+
def delete_template(template_id):
|
|
246
|
+
"""Delete a template by ID."""
|
|
247
|
+
Proposal.delete(template_id, is_template=True)
|
|
248
|
+
return jsonify({"ok": True})
|
|
249
|
+
|
|
250
|
+
@app.route("/api/proposal/<proposal_id>/save-as-template", methods=["POST"])
|
|
251
|
+
def save_as_template(proposal_id):
|
|
252
|
+
"""Save a proposal as a reusable template."""
|
|
253
|
+
data = request.get_json()
|
|
254
|
+
template_name = data.get("template_name", "").strip()
|
|
255
|
+
template_category = data.get("template_category", "").strip()
|
|
256
|
+
if not template_name:
|
|
257
|
+
return jsonify({"error": "Template name required"}), 400
|
|
258
|
+
|
|
259
|
+
proposal = Proposal.load(proposal_id)
|
|
260
|
+
if not proposal:
|
|
261
|
+
return jsonify({"error": "Not found"}), 404
|
|
262
|
+
|
|
263
|
+
new_id = re.sub(r"[^a-z0-9_-]", "_", template_name.lower())
|
|
264
|
+
new_id = re.sub(r"_+", "_", new_id).strip("_")
|
|
265
|
+
if not new_id:
|
|
266
|
+
new_id = uuid.uuid4().hex[:8]
|
|
267
|
+
|
|
268
|
+
original_id = new_id
|
|
269
|
+
counter = 1
|
|
270
|
+
while Proposal.load(new_id, is_template=True):
|
|
271
|
+
new_id = f"{original_id}_{counter}"
|
|
272
|
+
counter += 1
|
|
273
|
+
|
|
274
|
+
tmpl = Proposal(id=new_id, title=proposal.title)
|
|
275
|
+
tmpl.client_name = proposal.client_name
|
|
276
|
+
tmpl.subtitle = getattr(proposal, 'subtitle', '') or ''
|
|
277
|
+
tmpl.project_summary = proposal.project_summary
|
|
278
|
+
tmpl.scope = getattr(proposal, 'scope', '') or ''
|
|
279
|
+
tmpl.tasks = list(proposal.tasks)
|
|
280
|
+
tmpl.qualifications = proposal.qualifications
|
|
281
|
+
tmpl.budget_items = list(proposal.budget_items)
|
|
282
|
+
tmpl.budget_item_timings = dict(proposal.budget_item_timings) if proposal.budget_item_timings else {}
|
|
283
|
+
tmpl.indirect_percent = getattr(proposal, 'indirect_percent', 0) or 0
|
|
284
|
+
tmpl.show_budget_description = getattr(proposal, 'show_budget_description', False)
|
|
285
|
+
tmpl.budget_description = getattr(proposal, 'budget_description', '') or ''
|
|
286
|
+
tmpl.custom_sections = list(proposal.custom_sections) if proposal.custom_sections else []
|
|
287
|
+
tmpl.timeline_use_days = proposal.timeline_use_days
|
|
288
|
+
tmpl.timeline_show_budget = proposal.timeline_show_budget
|
|
289
|
+
tmpl.is_template = True
|
|
290
|
+
tmpl.template_name = template_name
|
|
291
|
+
tmpl.template_category = template_category
|
|
292
|
+
tmpl.save()
|
|
293
|
+
|
|
294
|
+
return jsonify({"id": new_id}), 201
|
|
295
|
+
|
|
296
|
+
@app.route("/templates/new-from/<template_id>")
|
|
297
|
+
def new_from_template(template_id):
|
|
298
|
+
"""Create a new proposal from a template and redirect to editor."""
|
|
299
|
+
tmpl = Proposal.load(template_id, is_template=True)
|
|
300
|
+
if not tmpl:
|
|
301
|
+
return redirect(url_for("templates_page"))
|
|
302
|
+
|
|
303
|
+
new_id = uuid.uuid4().hex[:8]
|
|
304
|
+
proposal = Proposal(id=new_id, title=tmpl.title)
|
|
305
|
+
proposal.client_name = tmpl.client_name
|
|
306
|
+
proposal.subtitle = getattr(tmpl, 'subtitle', '') or ''
|
|
307
|
+
proposal.project_summary = tmpl.project_summary
|
|
308
|
+
proposal.scope = getattr(tmpl, 'scope', '') or ''
|
|
309
|
+
proposal.tasks = list(tmpl.tasks)
|
|
310
|
+
proposal.qualifications = tmpl.qualifications
|
|
311
|
+
proposal.budget_items = list(tmpl.budget_items)
|
|
312
|
+
proposal.budget_item_timings = dict(tmpl.budget_item_timings) if tmpl.budget_item_timings else {}
|
|
313
|
+
proposal.indirect_percent = getattr(tmpl, 'indirect_percent', 0) or 0
|
|
314
|
+
proposal.show_budget_description = getattr(tmpl, 'show_budget_description', False)
|
|
315
|
+
proposal.budget_description = getattr(tmpl, 'budget_description', '') or ''
|
|
316
|
+
proposal.custom_sections = list(tmpl.custom_sections) if tmpl.custom_sections else []
|
|
317
|
+
proposal.timeline_use_days = tmpl.timeline_use_days
|
|
318
|
+
proposal.timeline_show_budget = tmpl.timeline_show_budget
|
|
319
|
+
proposal.save()
|
|
320
|
+
|
|
321
|
+
return redirect(url_for("editor", proposal_id=proposal.id))
|
|
322
|
+
|
|
323
|
+
@app.route("/scope/<proposal_id>")
|
|
324
|
+
def scope_tab(proposal_id: str) -> Tuple[str, int] | Tuple[Response, int]:
|
|
325
|
+
"""Render the scope/editing tab for a proposal."""
|
|
326
|
+
proposal = Proposal.load(proposal_id)
|
|
327
|
+
if not proposal:
|
|
328
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
329
|
+
return render_template("scope.html", proposal=proposal, tasks=proposal.tasks)
|
|
330
|
+
|
|
331
|
+
@app.route("/budget/<proposal_id>")
|
|
332
|
+
def budget_tab(proposal_id):
|
|
333
|
+
"""Render the budget tab with cost breakdowns per task."""
|
|
334
|
+
proposal = Proposal.load(proposal_id)
|
|
335
|
+
if not proposal:
|
|
336
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
337
|
+
|
|
338
|
+
task_budgets = {}
|
|
339
|
+
for task in proposal.tasks:
|
|
340
|
+
items = [b for b in proposal.budget_items if b.get("task_id") == task["id"]]
|
|
341
|
+
subtotal = sum(i.get("cost_per_unit", 0) * i.get("units", 0) for i in items)
|
|
342
|
+
task_budgets[task["id"]] = {
|
|
343
|
+
"task": task,
|
|
344
|
+
"items": items,
|
|
345
|
+
"subtotal": subtotal,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
indirect_percent = getattr(proposal, 'indirect_percent', 0) or 0
|
|
349
|
+
indirect_amount = proposal.total_budget * (indirect_percent / 100)
|
|
350
|
+
total_with_indirect = proposal.total_budget + indirect_amount
|
|
351
|
+
|
|
352
|
+
return render_template(
|
|
353
|
+
"budget.html",
|
|
354
|
+
proposal=proposal,
|
|
355
|
+
tasks=proposal.tasks,
|
|
356
|
+
budget_items=proposal.budget_items,
|
|
357
|
+
total_budget=proposal.total_budget,
|
|
358
|
+
task_budgets=task_budgets,
|
|
359
|
+
indirect_percent=indirect_percent,
|
|
360
|
+
indirect_amount=indirect_amount,
|
|
361
|
+
total_with_indirect=total_with_indirect,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
@app.route("/qualifications/<proposal_id>")
|
|
365
|
+
def qualifications_tab(proposal_id):
|
|
366
|
+
"""Render the qualifications tab for a proposal."""
|
|
367
|
+
proposal = Proposal.load(proposal_id)
|
|
368
|
+
if not proposal:
|
|
369
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
370
|
+
return render_template("qualifications.html", proposal=proposal)
|
|
371
|
+
|
|
372
|
+
@app.route("/timeline/<proposal_id>")
|
|
373
|
+
def timeline_tab(proposal_id):
|
|
374
|
+
"""Render the timeline tab with scheduling information."""
|
|
375
|
+
proposal = Proposal.load(proposal_id)
|
|
376
|
+
if not proposal:
|
|
377
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
378
|
+
|
|
379
|
+
task_budgets = {}
|
|
380
|
+
timings = proposal.budget_item_timings or {}
|
|
381
|
+
for task in proposal.tasks:
|
|
382
|
+
items = [b for b in proposal.budget_items if b.get("task_id") == task["id"]]
|
|
383
|
+
for item in items:
|
|
384
|
+
t = timings.get(item.get("id", ""), {})
|
|
385
|
+
if t:
|
|
386
|
+
item["start_month"] = t.get("start_month")
|
|
387
|
+
item["start_year"] = t.get("start_year")
|
|
388
|
+
item["duration_months"] = t.get("duration_months", 1)
|
|
389
|
+
if t.get("lead_entity"):
|
|
390
|
+
item["lead_entity"] = t["lead_entity"]
|
|
391
|
+
item["recurring"] = t.get("recurring", False)
|
|
392
|
+
item["recurring_interval"] = t.get("recurring_interval", 3)
|
|
393
|
+
if items:
|
|
394
|
+
task_budgets[task["id"]] = {
|
|
395
|
+
"task": task,
|
|
396
|
+
"items": items,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
sd = datetime.strptime(proposal.start_date, "%Y-%m-%d")
|
|
401
|
+
start_date_month = sd.month
|
|
402
|
+
start_date_year = sd.year
|
|
403
|
+
except (ValueError, TypeError):
|
|
404
|
+
now = datetime.now()
|
|
405
|
+
start_date_month = now.month
|
|
406
|
+
start_date_year = now.year
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
ed = datetime.strptime(proposal.end_date, "%Y-%m-%d") if proposal.end_date else None
|
|
410
|
+
end_date_month = ed.month if ed else start_date_month
|
|
411
|
+
end_date_year = ed.year if ed else start_date_year + 1
|
|
412
|
+
except (ValueError, TypeError):
|
|
413
|
+
end_date_month = start_date_month
|
|
414
|
+
end_date_year = start_date_year + 1
|
|
415
|
+
|
|
416
|
+
return render_template(
|
|
417
|
+
"timeline.html",
|
|
418
|
+
proposal=proposal,
|
|
419
|
+
tasks=proposal.tasks,
|
|
420
|
+
start_date=proposal.start_date,
|
|
421
|
+
start_date_month=start_date_month,
|
|
422
|
+
start_date_year=start_date_year,
|
|
423
|
+
end_date=proposal.end_date,
|
|
424
|
+
end_date_month=end_date_month,
|
|
425
|
+
end_date_year=end_date_year,
|
|
426
|
+
task_budgets=task_budgets,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
@app.route("/custom-sections/<proposal_id>")
|
|
430
|
+
def custom_sections_tab(proposal_id):
|
|
431
|
+
"""Render the custom sections tab for a proposal."""
|
|
432
|
+
proposal = Proposal.load(proposal_id)
|
|
433
|
+
if not proposal:
|
|
434
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
435
|
+
custom_sections = getattr(proposal, 'custom_sections', [])
|
|
436
|
+
sections = sorted(custom_sections, key=lambda s: s.get("order", 0))
|
|
437
|
+
return render_template(
|
|
438
|
+
"custom_sections.html",
|
|
439
|
+
proposal=proposal,
|
|
440
|
+
sections=sections
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
@app.route("/api/section/<proposal_id>", methods=["POST"])
|
|
444
|
+
def add_section(proposal_id):
|
|
445
|
+
"""Add a new custom section to a proposal."""
|
|
446
|
+
proposal = Proposal.load(proposal_id)
|
|
447
|
+
if not proposal:
|
|
448
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
449
|
+
|
|
450
|
+
data = request.get_json()
|
|
451
|
+
custom_sections = getattr(proposal, 'custom_sections', [])
|
|
452
|
+
new_section = {
|
|
453
|
+
"id": str(uuid.uuid4()),
|
|
454
|
+
"title": data.get("title", "New Section"),
|
|
455
|
+
"content": data.get("content", ""),
|
|
456
|
+
"order": len(custom_sections)
|
|
457
|
+
}
|
|
458
|
+
if not hasattr(proposal, 'custom_sections') or proposal.custom_sections is None:
|
|
459
|
+
proposal.custom_sections = []
|
|
460
|
+
proposal.custom_sections.append(new_section)
|
|
461
|
+
proposal.save()
|
|
462
|
+
return jsonify(new_section), 201
|
|
463
|
+
|
|
464
|
+
@app.route("/api/section/<proposal_id>/<section_id>", methods=["PUT"])
|
|
465
|
+
def update_section(proposal_id, section_id):
|
|
466
|
+
"""Update an existing custom section."""
|
|
467
|
+
proposal = Proposal.load(proposal_id)
|
|
468
|
+
if not proposal:
|
|
469
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
470
|
+
|
|
471
|
+
data = request.get_json()
|
|
472
|
+
sections = getattr(proposal, 'custom_sections', [])
|
|
473
|
+
for section in sections:
|
|
474
|
+
if section["id"] == section_id:
|
|
475
|
+
section.update({
|
|
476
|
+
"title": data.get("title", section["title"]),
|
|
477
|
+
"content": data.get("content", section["content"]),
|
|
478
|
+
"order": data.get("order", section.get("order", 0))
|
|
479
|
+
})
|
|
480
|
+
break
|
|
481
|
+
|
|
482
|
+
proposal.save()
|
|
483
|
+
return jsonify({"ok": True})
|
|
484
|
+
|
|
485
|
+
@app.route("/api/section/<proposal_id>/<section_id>", methods=["DELETE"])
|
|
486
|
+
def delete_section(proposal_id, section_id):
|
|
487
|
+
"""Delete a custom section from a proposal."""
|
|
488
|
+
proposal = Proposal.load(proposal_id)
|
|
489
|
+
if not proposal:
|
|
490
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
491
|
+
|
|
492
|
+
sections = getattr(proposal, 'custom_sections', [])
|
|
493
|
+
proposal.custom_sections = [
|
|
494
|
+
s for s in sections if s["id"] != section_id
|
|
495
|
+
]
|
|
496
|
+
proposal.save()
|
|
497
|
+
return jsonify({"ok": True})
|
|
498
|
+
|
|
499
|
+
@app.route("/api/section/<proposal_id>/import-excel", methods=["POST"])
|
|
500
|
+
def import_excel_section(proposal_id):
|
|
501
|
+
"""Import an Excel file as a markdown table custom section."""
|
|
502
|
+
proposal = Proposal.load(proposal_id)
|
|
503
|
+
if not proposal:
|
|
504
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
505
|
+
|
|
506
|
+
if 'file' not in request.files:
|
|
507
|
+
return jsonify({"error": "No file provided"}), 400
|
|
508
|
+
|
|
509
|
+
file = request.files['file']
|
|
510
|
+
if file.filename == '':
|
|
511
|
+
return jsonify({"error": "No file selected"}), 400
|
|
512
|
+
|
|
513
|
+
if not file.filename.endswith(('.xlsx', '.xls')):
|
|
514
|
+
return jsonify({"error": "Only Excel files (.xlsx, .xls) are supported"}), 400
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
import pandas as pd
|
|
518
|
+
import io
|
|
519
|
+
except ImportError:
|
|
520
|
+
logger.error("Pandas/openpyxl not installed")
|
|
521
|
+
return jsonify(ERROR_MESSAGES['EXCEL_NOT_INSTALLED']), 500
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
# Read Excel file
|
|
525
|
+
excel_data = file.read()
|
|
526
|
+
excel_file = io.BytesIO(excel_data)
|
|
527
|
+
df = pd.read_excel(excel_file, engine='openpyxl' if file.filename.endswith('.xlsx') else 'xlrd')
|
|
528
|
+
|
|
529
|
+
# Convert DataFrame to markdown table
|
|
530
|
+
markdown_content = df.to_markdown(index=False)
|
|
531
|
+
|
|
532
|
+
# Create new section with Excel data
|
|
533
|
+
custom_sections = getattr(proposal, 'custom_sections', [])
|
|
534
|
+
new_section = {
|
|
535
|
+
"id": str(uuid.uuid4()),
|
|
536
|
+
"title": request.form.get('title', file.filename),
|
|
537
|
+
"content": markdown_content,
|
|
538
|
+
"order": len(custom_sections)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if not hasattr(proposal, 'custom_sections') or proposal.custom_sections is None:
|
|
542
|
+
proposal.custom_sections = []
|
|
543
|
+
proposal.custom_sections.append(new_section)
|
|
544
|
+
proposal.save()
|
|
545
|
+
|
|
546
|
+
logger.info(f"Imported Excel file as section: {new_section['id']}")
|
|
547
|
+
return jsonify(new_section), 201
|
|
548
|
+
|
|
549
|
+
except (ValueError, KeyError) as e:
|
|
550
|
+
logger.warning(f"Invalid Excel file: {e}")
|
|
551
|
+
return jsonify({**ERROR_MESSAGES['EXCEL_INVALID_FILE'], 'details': str(e)}), 400
|
|
552
|
+
except Exception as e:
|
|
553
|
+
logger.error(f"Excel import failed: {e}")
|
|
554
|
+
return jsonify(ERROR_MESSAGES['EXCEL_PROCESSING_ERROR']), 500
|
|
555
|
+
|
|
556
|
+
@app.route("/api/section/<proposal_id>/reorder", methods=["PUT"])
|
|
557
|
+
def reorder_sections(proposal_id):
|
|
558
|
+
"""Reorder custom sections within a proposal."""
|
|
559
|
+
proposal = Proposal.load(proposal_id)
|
|
560
|
+
if not proposal:
|
|
561
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
562
|
+
|
|
563
|
+
data = request.get_json()
|
|
564
|
+
section_order = data.get('section_order', [])
|
|
565
|
+
|
|
566
|
+
sections = getattr(proposal, 'custom_sections', [])
|
|
567
|
+
section_dict = {s['id']: s for s in sections}
|
|
568
|
+
|
|
569
|
+
reordered = []
|
|
570
|
+
for idx, section_id in enumerate(section_order):
|
|
571
|
+
if section_id in section_dict:
|
|
572
|
+
section = section_dict[section_id]
|
|
573
|
+
section['order'] = idx
|
|
574
|
+
reordered.append(section)
|
|
575
|
+
|
|
576
|
+
proposal.custom_sections = reordered
|
|
577
|
+
proposal.save()
|
|
578
|
+
return jsonify({"ok": True})
|
|
579
|
+
|
|
580
|
+
@app.route("/preview/<proposal_id>")
|
|
581
|
+
def preview_tab(proposal_id):
|
|
582
|
+
"""Render the proposal preview page."""
|
|
583
|
+
proposal = Proposal.load(proposal_id)
|
|
584
|
+
if not proposal:
|
|
585
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
586
|
+
|
|
587
|
+
ctx = build_export_context(proposal)
|
|
588
|
+
return render_template("preview.html", **ctx)
|
|
589
|
+
|
|
590
|
+
@app.route("/api/task/<proposal_id>", methods=["POST"])
|
|
591
|
+
def add_task(proposal_id):
|
|
592
|
+
"""Add a new task to a proposal."""
|
|
593
|
+
proposal = Proposal.load(proposal_id)
|
|
594
|
+
if not proposal:
|
|
595
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
596
|
+
|
|
597
|
+
data = request.get_json()
|
|
598
|
+
task = {
|
|
599
|
+
"id": uuid.uuid4().hex[:8],
|
|
600
|
+
"name": data.get("name", ""),
|
|
601
|
+
"description": data.get("description", ""),
|
|
602
|
+
"start_month": data.get("start_month", 1),
|
|
603
|
+
"start_year": data.get("start_year", datetime.now().year),
|
|
604
|
+
"duration_months": data.get("duration_months", 1),
|
|
605
|
+
"lead_months": data.get("lead_months", 0),
|
|
606
|
+
"lead_entity": data.get("lead_entity", ""),
|
|
607
|
+
"recurring": data.get("recurring", False),
|
|
608
|
+
"recurring_interval": data.get("recurring_interval", 3),
|
|
609
|
+
}
|
|
610
|
+
proposal.tasks.append(task)
|
|
611
|
+
proposal.save()
|
|
612
|
+
return jsonify(task), 201
|
|
613
|
+
|
|
614
|
+
@app.route("/api/task/<proposal_id>/<task_id>", methods=["DELETE"])
|
|
615
|
+
def delete_task(proposal_id, task_id):
|
|
616
|
+
"""Delete a task and its associated budget items."""
|
|
617
|
+
proposal = Proposal.load(proposal_id)
|
|
618
|
+
if not proposal:
|
|
619
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
620
|
+
|
|
621
|
+
proposal.tasks = [t for t in proposal.tasks if t.get("id") != task_id]
|
|
622
|
+
proposal.budget_items = [b for b in proposal.budget_items if b.get("task_id") != task_id]
|
|
623
|
+
proposal.save()
|
|
624
|
+
return "", 200
|
|
625
|
+
|
|
626
|
+
@app.route("/api/budget/<proposal_id>", methods=["POST"])
|
|
627
|
+
def add_budget_item(proposal_id):
|
|
628
|
+
"""Add a new budget item to a proposal."""
|
|
629
|
+
proposal = Proposal.load(proposal_id)
|
|
630
|
+
if not proposal:
|
|
631
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
632
|
+
|
|
633
|
+
data = request.get_json()
|
|
634
|
+
try:
|
|
635
|
+
item = {
|
|
636
|
+
"id": uuid.uuid4().hex[:8],
|
|
637
|
+
"task_id": data.get("task_id", ""),
|
|
638
|
+
"name": data.get("name", ""),
|
|
639
|
+
"cost_per_unit": validate_numeric(data.get("cost_per_unit", 0), "cost_per_unit", min_val=0.0),
|
|
640
|
+
"units": validate_numeric(data.get("units", 1), "units", min_val=0.0),
|
|
641
|
+
}
|
|
642
|
+
except ValueError as e:
|
|
643
|
+
logger.warning(f"Invalid budget item data: {e}")
|
|
644
|
+
return jsonify({**ERROR_MESSAGES['INVALID_NUMERIC'], 'details': str(e)}), 400
|
|
645
|
+
|
|
646
|
+
proposal.budget_items.append(item)
|
|
647
|
+
proposal.save()
|
|
648
|
+
logger.debug(f"Added budget item {item['id']} to proposal {proposal_id}")
|
|
649
|
+
return jsonify(item), 201
|
|
650
|
+
|
|
651
|
+
@app.route("/api/budget/<proposal_id>/<item_id>", methods=["DELETE"])
|
|
652
|
+
def delete_budget_item(proposal_id, item_id):
|
|
653
|
+
"""Delete a budget item from a proposal."""
|
|
654
|
+
proposal = Proposal.load(proposal_id)
|
|
655
|
+
if not proposal:
|
|
656
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
657
|
+
|
|
658
|
+
proposal.budget_items = [b for b in proposal.budget_items if b.get("id") != item_id]
|
|
659
|
+
proposal.save()
|
|
660
|
+
return jsonify({"ok": True})
|
|
661
|
+
|
|
662
|
+
@app.route("/api/budget/<proposal_id>/<item_id>", methods=["PUT"])
|
|
663
|
+
def update_budget_item(proposal_id: str, item_id: str) -> Tuple[Response, int]:
|
|
664
|
+
"""Update an existing budget item."""
|
|
665
|
+
proposal = Proposal.load(proposal_id)
|
|
666
|
+
if not proposal:
|
|
667
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
668
|
+
|
|
669
|
+
data = request.get_json()
|
|
670
|
+
if not data:
|
|
671
|
+
return jsonify(ERROR_MESSAGES['NO_DATA']), 400
|
|
672
|
+
|
|
673
|
+
for item in proposal.budget_items:
|
|
674
|
+
if item.get("id") == item_id:
|
|
675
|
+
try:
|
|
676
|
+
item["task_id"] = data.get("task_id", item.get("task_id", ""))
|
|
677
|
+
item["name"] = data.get("name", item.get("name", ""))
|
|
678
|
+
item["cost_per_unit"] = validate_numeric(
|
|
679
|
+
data.get("cost_per_unit", item.get("cost_per_unit", 0)),
|
|
680
|
+
"cost_per_unit",
|
|
681
|
+
min_val=0.0
|
|
682
|
+
)
|
|
683
|
+
item["units"] = validate_numeric(
|
|
684
|
+
data.get("units", item.get("units", 1)),
|
|
685
|
+
"units",
|
|
686
|
+
min_val=0.0
|
|
687
|
+
)
|
|
688
|
+
except ValueError as e:
|
|
689
|
+
logger.warning(f"Invalid budget item data: {e}")
|
|
690
|
+
return jsonify({**ERROR_MESSAGES['INVALID_NUMERIC'], 'details': str(e)}), 400
|
|
691
|
+
break
|
|
692
|
+
else:
|
|
693
|
+
return jsonify(ERROR_MESSAGES['BUDGET_ITEM_NOT_FOUND']), 404
|
|
694
|
+
|
|
695
|
+
proposal.save()
|
|
696
|
+
logger.debug(f"Updated budget item {item_id} in proposal {proposal_id}")
|
|
697
|
+
return jsonify({"ok": True}), 200
|
|
698
|
+
|
|
699
|
+
@app.route("/api/proposal/<proposal_id>/import-budget", methods=["POST"])
|
|
700
|
+
def import_budget(proposal_id):
|
|
701
|
+
"""Import budget items and tasks from an Excel file."""
|
|
702
|
+
proposal = Proposal.load(proposal_id)
|
|
703
|
+
if not proposal:
|
|
704
|
+
return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
|
|
705
|
+
|
|
706
|
+
if 'file' not in request.files:
|
|
707
|
+
return jsonify({"error": "No file provided"}), 400
|
|
708
|
+
|
|
709
|
+
file = request.files['file']
|
|
710
|
+
if file.filename == '':
|
|
711
|
+
return jsonify({"error": "No file selected"}), 400
|
|
712
|
+
|
|
713
|
+
if not file.filename.endswith(('.xlsx', '.xls')):
|
|
714
|
+
return jsonify({"error": "Only Excel files (.xlsx, .xls) are supported"}), 400
|
|
715
|
+
|
|
716
|
+
try:
|
|
717
|
+
from openpyxl import load_workbook
|
|
718
|
+
import io
|
|
719
|
+
except ImportError:
|
|
720
|
+
return jsonify({"error": "openpyxl not installed"}), 500
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
excel_data = file.read()
|
|
724
|
+
wb = load_workbook(io.BytesIO(excel_data), read_only=True)
|
|
725
|
+
ws = wb.active
|
|
726
|
+
|
|
727
|
+
rows = list(ws.iter_rows(values_only=True))
|
|
728
|
+
if len(rows) < 2:
|
|
729
|
+
return jsonify({"error": "File has no data rows"}), 400
|
|
730
|
+
|
|
731
|
+
header = [str(c).strip().lower() if c else "" for c in rows[0]]
|
|
732
|
+
if not all(h in header for h in ["task", "item", "cost/unit", "units"]):
|
|
733
|
+
return jsonify({"error": "Required columns: Task, Item, Cost/Unit, Units"}), 400
|
|
734
|
+
|
|
735
|
+
task_idx = header.index("task")
|
|
736
|
+
item_idx = header.index("item")
|
|
737
|
+
cost_idx = header.index("cost/unit")
|
|
738
|
+
units_idx = header.index("units")
|
|
739
|
+
|
|
740
|
+
existing_tasks = {t["name"].strip().lower(): t for t in proposal.tasks}
|
|
741
|
+
created_tasks = 0
|
|
742
|
+
created_items = 0
|
|
743
|
+
|
|
744
|
+
for row in rows[1:]:
|
|
745
|
+
task_name = str(row[task_idx]).strip() if row[task_idx] else ""
|
|
746
|
+
item_name = str(row[item_idx]).strip() if row[item_idx] else ""
|
|
747
|
+
|
|
748
|
+
if not task_name or not item_name:
|
|
749
|
+
continue
|
|
750
|
+
|
|
751
|
+
task_key = task_name.lower()
|
|
752
|
+
if task_key not in existing_tasks:
|
|
753
|
+
new_task = {
|
|
754
|
+
"id": uuid.uuid4().hex[:8],
|
|
755
|
+
"name": task_name,
|
|
756
|
+
"description": "",
|
|
757
|
+
"start_month": 1,
|
|
758
|
+
"start_year": datetime.now().year,
|
|
759
|
+
"duration_months": 12,
|
|
760
|
+
}
|
|
761
|
+
proposal.tasks.append(new_task)
|
|
762
|
+
existing_tasks[task_key] = new_task
|
|
763
|
+
created_tasks += 1
|
|
764
|
+
|
|
765
|
+
try:
|
|
766
|
+
cost = float(row[cost_idx]) if row[cost_idx] else 0
|
|
767
|
+
units = float(row[units_idx]) if row[units_idx] else 1
|
|
768
|
+
except (ValueError, TypeError):
|
|
769
|
+
cost = 0
|
|
770
|
+
units = 1
|
|
771
|
+
|
|
772
|
+
budget_item = {
|
|
773
|
+
"id": uuid.uuid4().hex[:8],
|
|
774
|
+
"task_id": existing_tasks[task_key]["id"],
|
|
775
|
+
"name": item_name,
|
|
776
|
+
"cost_per_unit": cost,
|
|
777
|
+
"units": units,
|
|
778
|
+
}
|
|
779
|
+
proposal.budget_items.append(budget_item)
|
|
780
|
+
created_items += 1
|
|
781
|
+
|
|
782
|
+
wb.close()
|
|
783
|
+
proposal.save()
|
|
784
|
+
|
|
785
|
+
return jsonify({
|
|
786
|
+
"ok": True,
|
|
787
|
+
"created_tasks": created_tasks,
|
|
788
|
+
"created_items": created_items,
|
|
789
|
+
}), 200
|
|
790
|
+
|
|
791
|
+
except Exception as e:
|
|
792
|
+
logger.error(f"Budget import failed: {e}")
|
|
793
|
+
return jsonify({"error": f"Failed to process file: {str(e)}"}), 500
|
|
794
|
+
|
|
795
|
+
@app.route("/api/budget-template", methods=["GET"])
|
|
796
|
+
def download_budget_template():
|
|
797
|
+
"""Download a sample Excel budget template."""
|
|
798
|
+
from openpyxl import Workbook
|
|
799
|
+
import io
|
|
800
|
+
|
|
801
|
+
wb = Workbook()
|
|
802
|
+
ws = wb.active
|
|
803
|
+
ws.title = "Budget Template"
|
|
804
|
+
ws.append(["Task", "Item", "Cost/Unit", "Units"])
|
|
805
|
+
ws.append(["Scoping", "Initial meeting", 200, 6])
|
|
806
|
+
ws.append(["Scoping", "Data collection", 200, 20])
|
|
807
|
+
ws.append(["Analysis", "Forest valuation", 300, 40])
|
|
808
|
+
ws.append(["Results", "Report writing", 250, 30])
|
|
809
|
+
|
|
810
|
+
buf = io.BytesIO()
|
|
811
|
+
wb.save(buf)
|
|
812
|
+
buf.seek(0)
|
|
813
|
+
|
|
814
|
+
return Response(
|
|
815
|
+
buf.getvalue(),
|
|
816
|
+
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
817
|
+
headers={"Content-Disposition": "attachment; filename=budget_template.xlsx"}
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
@app.route("/tracker/<proposal_id>")
|
|
821
|
+
def tracker(proposal_id):
|
|
822
|
+
"""Render the project tracker page with progress and milestones."""
|
|
823
|
+
proposal = Proposal.load(proposal_id)
|
|
824
|
+
if not proposal:
|
|
825
|
+
return redirect(url_for("index"))
|
|
826
|
+
|
|
827
|
+
indirect_percent = getattr(proposal, 'indirect_percent', 0) or 0
|
|
828
|
+
indirect_amount = proposal.total_budget * (indirect_percent / 100)
|
|
829
|
+
total_with_indirect = proposal.total_budget + indirect_amount
|
|
830
|
+
|
|
831
|
+
task_budgets = {}
|
|
832
|
+
timings = proposal.budget_item_timings or {}
|
|
833
|
+
for task in proposal.tasks:
|
|
834
|
+
items = [b for b in proposal.budget_items if b.get("task_id") == task["id"]]
|
|
835
|
+
for item in items:
|
|
836
|
+
t = timings.get(item.get("id", ""), {})
|
|
837
|
+
if t:
|
|
838
|
+
item["actual_cost"] = t.get("actual_cost", 0)
|
|
839
|
+
subtotal = sum(i.get("cost_per_unit", 0) * i.get("units", 0) for i in items)
|
|
840
|
+
actual_total = sum(i.get("actual_cost", 0) for i in items)
|
|
841
|
+
task_budgets[task["id"]] = {
|
|
842
|
+
"task": task,
|
|
843
|
+
"items": items,
|
|
844
|
+
"subtotal": subtotal,
|
|
845
|
+
"actual_total": actual_total,
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
milestones = getattr(proposal, 'milestones', []) or []
|
|
849
|
+
reports = getattr(proposal, 'reports', []) or []
|
|
850
|
+
|
|
851
|
+
completed_tasks = sum(1 for t in proposal.tasks if t.get("status") == "completed")
|
|
852
|
+
total_tasks = len(proposal.tasks)
|
|
853
|
+
overall_pct = round(completed_tasks / total_tasks * 100) if total_tasks else 0
|
|
854
|
+
|
|
855
|
+
return render_template(
|
|
856
|
+
"tracker.html",
|
|
857
|
+
proposal=proposal,
|
|
858
|
+
tasks=proposal.tasks,
|
|
859
|
+
task_budgets=task_budgets,
|
|
860
|
+
total_budget=proposal.total_budget,
|
|
861
|
+
indirect_percent=indirect_percent,
|
|
862
|
+
indirect_amount=indirect_amount,
|
|
863
|
+
total_with_indirect=total_with_indirect,
|
|
864
|
+
milestones=milestones,
|
|
865
|
+
reports=reports,
|
|
866
|
+
overall_pct=overall_pct,
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
@app.route("/api/tracker/<proposal_id>/task/<task_id>", methods=["PUT"])
|
|
870
|
+
def update_tracker_task(proposal_id, task_id):
|
|
871
|
+
"""Update task progress, status, or notes in the tracker."""
|
|
872
|
+
proposal = Proposal.load(proposal_id)
|
|
873
|
+
if not proposal:
|
|
874
|
+
return jsonify({"error": "Not found"}), 404
|
|
875
|
+
|
|
876
|
+
data = request.get_json()
|
|
877
|
+
for task in proposal.tasks:
|
|
878
|
+
if task.get("id") == task_id:
|
|
879
|
+
if "status" in data:
|
|
880
|
+
task["status"] = data["status"]
|
|
881
|
+
if "progress_pct" in data:
|
|
882
|
+
task["progress_pct"] = int(data["progress_pct"])
|
|
883
|
+
if "actual_start" in data:
|
|
884
|
+
task["actual_start"] = data["actual_start"]
|
|
885
|
+
if "actual_end" in data:
|
|
886
|
+
task["actual_end"] = data["actual_end"]
|
|
887
|
+
if "notes" in data:
|
|
888
|
+
task["notes"] = data["notes"]
|
|
889
|
+
break
|
|
890
|
+
else:
|
|
891
|
+
return jsonify({"error": "Task not found"}), 404
|
|
892
|
+
|
|
893
|
+
proposal.save()
|
|
894
|
+
return jsonify({"ok": True})
|
|
895
|
+
|
|
896
|
+
@app.route("/api/tracker/<proposal_id>/budget/<item_id>", methods=["PUT"])
|
|
897
|
+
def update_tracker_budget(proposal_id, item_id):
|
|
898
|
+
"""Update the actual cost for a budget item in the tracker."""
|
|
899
|
+
proposal = Proposal.load(proposal_id)
|
|
900
|
+
if not proposal:
|
|
901
|
+
return jsonify({"error": "Not found"}), 404
|
|
902
|
+
|
|
903
|
+
data = request.get_json()
|
|
904
|
+
timings = proposal.budget_item_timings or {}
|
|
905
|
+
if item_id not in timings:
|
|
906
|
+
timings[item_id] = {}
|
|
907
|
+
if "actual_cost" in data:
|
|
908
|
+
timings[item_id]["actual_cost"] = float(data["actual_cost"])
|
|
909
|
+
proposal.budget_item_timings = timings
|
|
910
|
+
proposal.save()
|
|
911
|
+
return jsonify({"ok": True})
|
|
912
|
+
|
|
913
|
+
@app.route("/api/tracker/<proposal_id>/milestone", methods=["POST"])
|
|
914
|
+
def add_milestone(proposal_id):
|
|
915
|
+
"""Add a new milestone to the tracker."""
|
|
916
|
+
proposal = Proposal.load(proposal_id)
|
|
917
|
+
if not proposal:
|
|
918
|
+
return jsonify({"error": "Not found"}), 404
|
|
919
|
+
|
|
920
|
+
data = request.get_json()
|
|
921
|
+
milestones = getattr(proposal, 'milestones', []) or []
|
|
922
|
+
milestone = {
|
|
923
|
+
"id": uuid.uuid4().hex[:8],
|
|
924
|
+
"name": data.get("name", ""),
|
|
925
|
+
"date": data.get("date", ""),
|
|
926
|
+
"completed": False,
|
|
927
|
+
}
|
|
928
|
+
milestones.append(milestone)
|
|
929
|
+
proposal.milestones = milestones
|
|
930
|
+
proposal.save()
|
|
931
|
+
return jsonify(milestone), 201
|
|
932
|
+
|
|
933
|
+
@app.route("/api/tracker/<proposal_id>/milestone/<milestone_id>", methods=["PUT"])
|
|
934
|
+
def update_milestone(proposal_id, milestone_id):
|
|
935
|
+
"""Update a milestone's name, date, or completion status."""
|
|
936
|
+
proposal = Proposal.load(proposal_id)
|
|
937
|
+
if not proposal:
|
|
938
|
+
return jsonify({"error": "Not found"}), 404
|
|
939
|
+
|
|
940
|
+
data = request.get_json()
|
|
941
|
+
milestones = getattr(proposal, 'milestones', []) or []
|
|
942
|
+
for m in milestones:
|
|
943
|
+
if m["id"] == milestone_id:
|
|
944
|
+
if "name" in data:
|
|
945
|
+
m["name"] = data["name"]
|
|
946
|
+
if "date" in data:
|
|
947
|
+
m["date"] = data["date"]
|
|
948
|
+
if "completed" in data:
|
|
949
|
+
m["completed"] = data["completed"]
|
|
950
|
+
break
|
|
951
|
+
else:
|
|
952
|
+
return jsonify({"error": "Milestone not found"}), 404
|
|
953
|
+
|
|
954
|
+
proposal.milestones = milestones
|
|
955
|
+
proposal.save()
|
|
956
|
+
return jsonify({"ok": True})
|
|
957
|
+
|
|
958
|
+
@app.route("/api/tracker/<proposal_id>/milestone/<milestone_id>", methods=["DELETE"])
|
|
959
|
+
def delete_milestone(proposal_id, milestone_id):
|
|
960
|
+
"""Delete a milestone from the tracker."""
|
|
961
|
+
proposal = Proposal.load(proposal_id)
|
|
962
|
+
if not proposal:
|
|
963
|
+
return jsonify({"error": "Not found"}), 404
|
|
964
|
+
|
|
965
|
+
milestones = getattr(proposal, 'milestones', []) or []
|
|
966
|
+
proposal.milestones = [m for m in milestones if m["id"] != milestone_id]
|
|
967
|
+
proposal.save()
|
|
968
|
+
return jsonify({"ok": True})
|
|
969
|
+
|
|
970
|
+
@app.route("/api/tracker/<proposal_id>/report", methods=["POST"])
|
|
971
|
+
def add_report(proposal_id):
|
|
972
|
+
"""Add a new progress report to the tracker."""
|
|
973
|
+
proposal = Proposal.load(proposal_id)
|
|
974
|
+
if not proposal:
|
|
975
|
+
return jsonify({"error": "Not found"}), 404
|
|
976
|
+
|
|
977
|
+
data = request.get_json()
|
|
978
|
+
reports = getattr(proposal, 'reports', []) or []
|
|
979
|
+
report = {
|
|
980
|
+
"id": uuid.uuid4().hex[:8],
|
|
981
|
+
"date": data.get("date", datetime.now().strftime("%Y-%m-%d")),
|
|
982
|
+
"title": data.get("title", ""),
|
|
983
|
+
"content": data.get("content", ""),
|
|
984
|
+
}
|
|
985
|
+
reports.append(report)
|
|
986
|
+
proposal.reports = reports
|
|
987
|
+
proposal.save()
|
|
988
|
+
return jsonify(report), 201
|
|
989
|
+
|
|
990
|
+
@app.route("/api/tracker/<proposal_id>/report/<report_id>", methods=["PUT"])
|
|
991
|
+
def update_report(proposal_id, report_id):
|
|
992
|
+
"""Update a progress report's content, title, or date."""
|
|
993
|
+
proposal = Proposal.load(proposal_id)
|
|
994
|
+
if not proposal:
|
|
995
|
+
return jsonify({"error": "Not found"}), 404
|
|
996
|
+
|
|
997
|
+
data = request.get_json()
|
|
998
|
+
reports = getattr(proposal, 'reports', []) or []
|
|
999
|
+
for r in reports:
|
|
1000
|
+
if r["id"] == report_id:
|
|
1001
|
+
if "title" in data:
|
|
1002
|
+
r["title"] = data["title"]
|
|
1003
|
+
if "date" in data:
|
|
1004
|
+
r["date"] = data["date"]
|
|
1005
|
+
if "content" in data:
|
|
1006
|
+
r["content"] = data["content"]
|
|
1007
|
+
break
|
|
1008
|
+
else:
|
|
1009
|
+
return jsonify({"error": "Report not found"}), 404
|
|
1010
|
+
|
|
1011
|
+
proposal.reports = reports
|
|
1012
|
+
proposal.save()
|
|
1013
|
+
return jsonify({"ok": True})
|
|
1014
|
+
|
|
1015
|
+
@app.route("/api/tracker/<proposal_id>/report/<report_id>", methods=["DELETE"])
|
|
1016
|
+
def delete_report(proposal_id, report_id):
|
|
1017
|
+
"""Delete a progress report from the tracker."""
|
|
1018
|
+
proposal = Proposal.load(proposal_id)
|
|
1019
|
+
if not proposal:
|
|
1020
|
+
return jsonify({"error": "Not found"}), 404
|
|
1021
|
+
|
|
1022
|
+
reports = getattr(proposal, 'reports', []) or []
|
|
1023
|
+
proposal.reports = [r for r in reports if r["id"] != report_id]
|
|
1024
|
+
proposal.save()
|
|
1025
|
+
return jsonify({"ok": True})
|
|
1026
|
+
|
|
1027
|
+
return app
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def run_server() -> None:
|
|
1031
|
+
"""Run the development server."""
|
|
1032
|
+
import logging as _logging
|
|
1033
|
+
_logging.getLogger("werkzeug").setLevel(_logging.WARNING)
|
|
1034
|
+
app = create_app()
|
|
1035
|
+
print("\n ✨✨✨ 🔌 Server started ✨✨✨")
|
|
1036
|
+
print(f" 👉 http://localhost:{Config.PORT} 👈\n")
|
|
1037
|
+
app.run(debug=Config.DEBUG, host=Config.HOST, port=Config.PORT)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
if __name__ == "__main__":
|
|
1041
|
+
run_server()
|