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/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()