propongo2 0.1.1__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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
app/export.py ADDED
@@ -0,0 +1,104 @@
1
+ import os
2
+ from io import BytesIO
3
+ from weasyprint import HTML
4
+ from flask import Blueprint, render_template, request, send_file, jsonify, Response
5
+ from .models import Proposal
6
+
7
+ export_bp = Blueprint("export", __name__)
8
+
9
+ EXPORT_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "exports")
10
+
11
+
12
+ def ensure_export_dir():
13
+ os.makedirs(EXPORT_DIR, exist_ok=True)
14
+
15
+
16
+ def _build_export_context(proposal):
17
+ indirect_percent = getattr(proposal, 'indirect_percent', 0) or 0
18
+ indirect_amount = proposal.total_budget * (indirect_percent / 100)
19
+ total_with_indirect = proposal.total_budget + indirect_amount
20
+
21
+ tasks_with_timing = []
22
+ for t in proposal.tasks:
23
+ tasks_with_timing.append({
24
+ "id": t.get("id", ""),
25
+ "name": t.get("name", ""),
26
+ "description": t.get("description", ""),
27
+ "lead_entity": t.get("lead_entity", ""),
28
+ "start_month": t.get("start_month"),
29
+ "start_year": t.get("start_year"),
30
+ "duration_months": t.get("duration_months", 1),
31
+ })
32
+
33
+ budget_with_timing = []
34
+ timings = proposal.budget_item_timings or {}
35
+ for item in proposal.budget_items:
36
+ item_id = item.get("id", "")
37
+ timing = timings.get(item_id, {})
38
+ budget_with_timing.append({
39
+ **item,
40
+ "start_month": timing.get("start_month"),
41
+ "start_year": timing.get("start_year"),
42
+ "duration_months": timing.get("duration_months", 1),
43
+ "task_id": item.get("task_id", ""),
44
+ })
45
+
46
+ return {
47
+ "proposal": proposal,
48
+ "tasks": tasks_with_timing,
49
+ "budget_items": proposal.budget_items,
50
+ "budget_with_timing": budget_with_timing,
51
+ "total_budget": proposal.total_budget,
52
+ "indirect_percent": indirect_percent,
53
+ "indirect_amount": indirect_amount,
54
+ "total_with_indirect": total_with_indirect,
55
+ }
56
+
57
+
58
+ @export_bp.route("/export/pdf/<proposal_id>")
59
+ def export_pdf(proposal_id):
60
+ proposal = Proposal.load(proposal_id)
61
+ if not proposal:
62
+ return jsonify({"error": "Proposal not found"}), 404
63
+
64
+ ctx = _build_export_context(proposal)
65
+ html_content = render_template("export_proposal.html", **ctx)
66
+
67
+ ensure_export_dir()
68
+ pdf_path = os.path.join(EXPORT_DIR, f"{proposal_id}.pdf")
69
+ HTML(string=html_content, base_url=request.host_url).write_pdf(pdf_path)
70
+
71
+ return send_file(
72
+ pdf_path,
73
+ mimetype="application/pdf",
74
+ as_attachment=True,
75
+ download_name=f"{proposal.title or 'proposal'}.pdf",
76
+ )
77
+
78
+
79
+ @export_bp.route("/export/html/<proposal_id>")
80
+ def export_html(proposal_id):
81
+ proposal = Proposal.load(proposal_id)
82
+ if not proposal:
83
+ return jsonify({"error": "Proposal not found"}), 404
84
+
85
+ ctx = _build_export_context(proposal)
86
+ html_content = render_template("export_proposal.html", **ctx)
87
+
88
+ return Response(
89
+ html_content,
90
+ mimetype="text/html",
91
+ headers={
92
+ "Content-Disposition": f"inline; filename={proposal.title or 'proposal'}.html"
93
+ },
94
+ )
95
+
96
+
97
+ @export_bp.route("/preview/<proposal_id>")
98
+ def preview(proposal_id):
99
+ proposal = Proposal.load(proposal_id)
100
+ if not proposal:
101
+ return jsonify({"error": "Proposal not found"}), 404
102
+
103
+ ctx = _build_export_context(proposal)
104
+ return render_template("export_proposal.html", **ctx)
app/main.py ADDED
@@ -0,0 +1,446 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import uuid as _uuid
5
+ from flask import Flask, render_template, request, jsonify, redirect, url_for
6
+ from markupsafe import Markup
7
+ from .models import Proposal, PROPOSALS_DIR
8
+ from .export import export_bp
9
+ from .snippets import snippets_bp
10
+
11
+
12
+ def markdown_to_html(text):
13
+ if not text:
14
+ return ""
15
+ lines = text.split("\n")
16
+ html_lines = []
17
+ in_list = False
18
+ in_ol = False
19
+
20
+ for line in lines:
21
+ stripped = line.strip()
22
+
23
+ if not stripped:
24
+ if in_list:
25
+ html_lines.append("</ul>")
26
+ in_list = False
27
+ if in_ol:
28
+ html_lines.append("</ol>")
29
+ in_ol = False
30
+ continue
31
+
32
+ if stripped.startswith("######"):
33
+ if in_list: html_lines.append("</ul>"); in_list = False
34
+ if in_ol: html_lines.append("</ol>"); in_ol = False
35
+ html_lines.append(f"<h6>{_md_inline(stripped[6:])}</h6>")
36
+ elif stripped.startswith("#####"):
37
+ if in_list: html_lines.append("</ul>"); in_list = False
38
+ if in_ol: html_lines.append("</ol>"); in_ol = False
39
+ html_lines.append(f"<h5>{_md_inline(stripped[5:])}</h5>")
40
+ elif stripped.startswith("####"):
41
+ if in_list: html_lines.append("</ul>"); in_list = False
42
+ if in_ol: html_lines.append("</ol>"); in_ol = False
43
+ html_lines.append(f"<h4>{_md_inline(stripped[4:])}</h4>")
44
+ elif stripped.startswith("###"):
45
+ if in_list: html_lines.append("</ul>"); in_list = False
46
+ if in_ol: html_lines.append("</ol>"); in_ol = False
47
+ html_lines.append(f"<h3>{_md_inline(stripped[3:])}</h3>")
48
+ elif stripped.startswith("##"):
49
+ if in_list: html_lines.append("</ul>"); in_list = False
50
+ if in_ol: html_lines.append("</ol>"); in_ol = False
51
+ html_lines.append(f"<h2>{_md_inline(stripped[2:])}</h2>")
52
+ elif stripped.startswith("#"):
53
+ if in_list: html_lines.append("</ul>"); in_list = False
54
+ if in_ol: html_lines.append("</ol>"); in_ol = False
55
+ html_lines.append(f"<h1>{_md_inline(stripped[1:])}</h1>")
56
+ elif re.match(r"^[-*+]\s+", stripped):
57
+ if in_ol: html_lines.append("</ol>"); in_ol = False
58
+ if not in_list:
59
+ html_lines.append("<ul>")
60
+ in_list = True
61
+ content = re.sub(r"^[-*+]\s+", "", stripped)
62
+ html_lines.append(f"<li>{_md_inline(content)}</li>")
63
+ elif re.match(r"^\d+\.\s+", stripped):
64
+ if in_list: html_lines.append("</ul>"); in_list = False
65
+ if not in_ol:
66
+ html_lines.append("<ol>")
67
+ in_ol = True
68
+ content = re.sub(r"^\d+\.\s+", "", stripped)
69
+ html_lines.append(f"<li>{_md_inline(content)}</li>")
70
+ else:
71
+ if in_list: html_lines.append("</ul>"); in_list = False
72
+ if in_ol: html_lines.append("</ol>"); in_ol = False
73
+ html_lines.append(f"<p>{_md_inline(stripped)}</p>")
74
+
75
+ if in_list:
76
+ html_lines.append("</ul>")
77
+ if in_ol:
78
+ html_lines.append("</ol>")
79
+
80
+ return "\n".join(html_lines)
81
+
82
+
83
+ def _md_inline(text):
84
+ text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
85
+ text = re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
86
+ text = re.sub(r"`(.+?)`", r"<code>\1</code>", text)
87
+ return text
88
+
89
+
90
+ def create_app():
91
+ app = Flask(__name__)
92
+ app.secret_key = "propongo2-dev-key-change-in-production"
93
+
94
+ app.register_blueprint(export_bp)
95
+ app.register_blueprint(snippets_bp)
96
+
97
+ app.jinja_env.filters["md"] = lambda text: Markup(markdown_to_html(text))
98
+ app.jinja_env.filters["currency"] = lambda value: f"{value:,.2f}"
99
+
100
+ @app.route("/")
101
+ def index():
102
+ proposals = Proposal.list_all()
103
+ return render_template("index.html", proposals=proposals)
104
+
105
+ @app.route("/new")
106
+ def new_proposal():
107
+ proposal = Proposal()
108
+ proposal.save()
109
+ return redirect(url_for("editor", proposal_id=proposal.id))
110
+
111
+ @app.route("/editor/<proposal_id>")
112
+ def editor(proposal_id):
113
+ proposal = Proposal.load(proposal_id)
114
+ if not proposal:
115
+ return redirect(url_for("index"))
116
+ return render_template(
117
+ "base.html",
118
+ proposal=proposal,
119
+ tasks=proposal.tasks,
120
+ budget_items=proposal.budget_items,
121
+ )
122
+
123
+ @app.route("/api/proposal/<proposal_id>", methods=["GET"])
124
+ def get_proposal(proposal_id):
125
+ proposal = Proposal.load(proposal_id)
126
+ if not proposal:
127
+ return jsonify({"error": "Not found"}), 404
128
+ return jsonify(proposal.to_dict())
129
+
130
+ @app.route("/api/proposal/<proposal_id>", methods=["PUT"])
131
+ def save_proposal(proposal_id):
132
+ data = request.get_json()
133
+ if not data:
134
+ return jsonify({"error": "No data"}), 400
135
+
136
+ proposal = Proposal.load(proposal_id)
137
+ if not proposal:
138
+ proposal = Proposal(id=proposal_id)
139
+
140
+ if "tasks" in data:
141
+ incoming_tasks = data.pop("tasks")
142
+ existing_by_id = {t.get("id"): t for t in proposal.tasks}
143
+ merged = []
144
+ for t in incoming_tasks:
145
+ tid = t.get("id", "")
146
+ if tid in existing_by_id:
147
+ merged_task = dict(existing_by_id[tid])
148
+ merged_task.update(t)
149
+ else:
150
+ merged_task = t
151
+ merged.append(merged_task)
152
+ proposal.tasks = merged
153
+
154
+ new_title = data.get("title")
155
+ new_id = None
156
+ if new_title is not None and new_title.strip() and new_title.strip() != proposal.title:
157
+ candidate = re.sub(r"[^a-z0-9_-]", "_", new_title.strip().lower())
158
+ candidate = re.sub(r"_+", "_", candidate).strip("_")
159
+ if not candidate:
160
+ candidate = _uuid.uuid4().hex[:8]
161
+ original = candidate
162
+ counter = 1
163
+ while Proposal.load(candidate) and candidate != proposal_id:
164
+ candidate = f"{original}_{counter}"
165
+ counter += 1
166
+ if candidate != proposal_id:
167
+ old_path = os.path.join(PROPOSALS_DIR, f"{proposal_id}.json")
168
+ proposal.id = candidate
169
+ new_id = candidate
170
+ for key, value in data.items():
171
+ if hasattr(proposal, key):
172
+ setattr(proposal, key, value)
173
+ proposal.save()
174
+ if os.path.exists(old_path):
175
+ os.remove(old_path)
176
+ return jsonify({"id": new_id, **proposal.to_dict()})
177
+
178
+ for key, value in data.items():
179
+ if hasattr(proposal, key):
180
+ setattr(proposal, key, value)
181
+
182
+ proposal.save()
183
+ return jsonify(proposal.to_dict())
184
+
185
+ @app.route("/api/proposal/<proposal_id>", methods=["DELETE"])
186
+ def delete_proposal(proposal_id):
187
+ Proposal.delete(proposal_id)
188
+ return jsonify({"ok": True})
189
+
190
+ @app.route("/api/proposal/<proposal_id>/save-as", methods=["POST"])
191
+ def save_proposal_as(proposal_id):
192
+ data = request.get_json()
193
+ title = data.get("title", "").strip()
194
+ if not title:
195
+ return jsonify({"error": "Title required"}), 400
196
+
197
+ proposal = Proposal.load(proposal_id)
198
+ if not proposal:
199
+ return jsonify({"error": "Not found"}), 404
200
+
201
+ new_id = re.sub(r"[^a-z0-9_-]", "_", title.lower())
202
+ new_id = re.sub(r"_+", "_", new_id).strip("_")
203
+ if not new_id:
204
+ new_id = _uuid.uuid4().hex[:8]
205
+
206
+ original_id = new_id
207
+ counter = 1
208
+ while Proposal.load(new_id):
209
+ new_id = f"{original_id}_{counter}"
210
+ counter += 1
211
+
212
+ new_proposal = Proposal(id=new_id, title=title)
213
+ new_proposal.client_name = proposal.client_name
214
+ new_proposal.project_summary = proposal.project_summary
215
+ new_proposal.tasks = list(proposal.tasks)
216
+ new_proposal.qualifications = proposal.qualifications
217
+ new_proposal.budget_items = list(proposal.budget_items)
218
+ new_proposal.budget_item_timings = dict(proposal.budget_item_timings) if proposal.budget_item_timings else {}
219
+ new_proposal.start_date = proposal.start_date
220
+ new_proposal.indirect_percent = getattr(proposal, 'indirect_percent', 0) or 0
221
+ new_proposal.save()
222
+
223
+ return jsonify({"id": new_id}), 201
224
+
225
+ @app.route("/api/proposals", methods=["GET"])
226
+ def list_proposals():
227
+ return jsonify(Proposal.list_all())
228
+
229
+ @app.route("/scope/<proposal_id>")
230
+ def scope_tab(proposal_id):
231
+ proposal = Proposal.load(proposal_id)
232
+ if not proposal:
233
+ return jsonify({"error": "Not found"}), 404
234
+ return render_template("scope.html", proposal=proposal, tasks=proposal.tasks)
235
+
236
+ @app.route("/budget/<proposal_id>")
237
+ def budget_tab(proposal_id):
238
+ proposal = Proposal.load(proposal_id)
239
+ if not proposal:
240
+ return jsonify({"error": "Not found"}), 404
241
+
242
+ task_budgets = {}
243
+ for task in proposal.tasks:
244
+ items = [b for b in proposal.budget_items if b.get("task_id") == task["id"]]
245
+ subtotal = sum(i.get("cost_per_unit", 0) * i.get("units", 0) for i in items)
246
+ task_budgets[task["id"]] = {
247
+ "task": task,
248
+ "items": items,
249
+ "subtotal": subtotal,
250
+ }
251
+
252
+ indirect_percent = getattr(proposal, 'indirect_percent', 0) or 0
253
+ indirect_amount = proposal.total_budget * (indirect_percent / 100)
254
+ total_with_indirect = proposal.total_budget + indirect_amount
255
+
256
+ return render_template(
257
+ "budget.html",
258
+ proposal=proposal,
259
+ tasks=proposal.tasks,
260
+ budget_items=proposal.budget_items,
261
+ total_budget=proposal.total_budget,
262
+ task_budgets=task_budgets,
263
+ indirect_percent=indirect_percent,
264
+ indirect_amount=indirect_amount,
265
+ total_with_indirect=total_with_indirect,
266
+ )
267
+
268
+ @app.route("/qualifications/<proposal_id>")
269
+ def qualifications_tab(proposal_id):
270
+ proposal = Proposal.load(proposal_id)
271
+ if not proposal:
272
+ return jsonify({"error": "Not found"}), 404
273
+ return render_template("qualifications.html", proposal=proposal)
274
+
275
+ @app.route("/timeline/<proposal_id>")
276
+ def timeline_tab(proposal_id):
277
+ proposal = Proposal.load(proposal_id)
278
+ if not proposal:
279
+ return jsonify({"error": "Not found"}), 404
280
+
281
+ task_budgets = {}
282
+ for task in proposal.tasks:
283
+ items = [b for b in proposal.budget_items if b.get("task_id") == task["id"]]
284
+ if items:
285
+ task_budgets[task["id"]] = {
286
+ "task": task,
287
+ "items": items,
288
+ }
289
+
290
+ from datetime import datetime as _dt
291
+ try:
292
+ sd = _dt.strptime(proposal.start_date, "%Y-%m-%d")
293
+ start_date_month = sd.month
294
+ start_date_year = sd.year
295
+ except (ValueError, TypeError):
296
+ now = _dt.now()
297
+ start_date_month = now.month
298
+ start_date_year = now.year
299
+
300
+ return render_template(
301
+ "timeline.html",
302
+ proposal=proposal,
303
+ tasks=proposal.tasks,
304
+ start_date=proposal.start_date,
305
+ start_date_month=start_date_month,
306
+ start_date_year=start_date_year,
307
+ task_budgets=task_budgets,
308
+ )
309
+
310
+ @app.route("/preview/<proposal_id>")
311
+ def preview_tab(proposal_id):
312
+ proposal = Proposal.load(proposal_id)
313
+ if not proposal:
314
+ return jsonify({"error": "Not found"}), 404
315
+
316
+ indirect_percent = getattr(proposal, 'indirect_percent', 0) or 0
317
+ indirect_amount = proposal.total_budget * (indirect_percent / 100)
318
+ total_with_indirect = proposal.total_budget + indirect_amount
319
+
320
+ tasks_with_timing = []
321
+ for t in proposal.tasks:
322
+ tasks_with_timing.append({
323
+ "id": t.get("id", ""),
324
+ "name": t.get("name", ""),
325
+ "description": t.get("description", ""),
326
+ "lead_entity": t.get("lead_entity", ""),
327
+ "start_month": t.get("start_month"),
328
+ "start_year": t.get("start_year"),
329
+ "duration_months": t.get("duration_months", 1),
330
+ })
331
+
332
+ budget_with_timing = []
333
+ timings = proposal.budget_item_timings or {}
334
+ for item in proposal.budget_items:
335
+ item_id = item.get("id", "")
336
+ timing = timings.get(item_id, {})
337
+ budget_with_timing.append({
338
+ **item,
339
+ "start_month": timing.get("start_month"),
340
+ "start_year": timing.get("start_year"),
341
+ "duration_months": timing.get("duration_months", 1),
342
+ "task_id": item.get("task_id", ""),
343
+ })
344
+
345
+ return render_template(
346
+ "preview.html",
347
+ proposal=proposal,
348
+ tasks=tasks_with_timing,
349
+ budget_items=proposal.budget_items,
350
+ budget_with_timing=budget_with_timing,
351
+ total_budget=proposal.total_budget,
352
+ indirect_percent=indirect_percent,
353
+ indirect_amount=indirect_amount,
354
+ total_with_indirect=total_with_indirect,
355
+ )
356
+
357
+ @app.route("/api/task/<proposal_id>", methods=["POST"])
358
+ def add_task(proposal_id):
359
+ proposal = Proposal.load(proposal_id)
360
+ if not proposal:
361
+ return jsonify({"error": "Not found"}), 404
362
+
363
+ data = request.get_json()
364
+ task = {
365
+ "id": __import__("uuid").uuid4().hex[:8],
366
+ "name": data.get("name", ""),
367
+ "description": data.get("description", ""),
368
+ "lead_months": data.get("lead_months", 0),
369
+ "duration_months": data.get("duration_months", 1),
370
+ }
371
+ proposal.tasks.append(task)
372
+ proposal.save()
373
+ return jsonify(task), 201
374
+
375
+ @app.route("/api/task/<proposal_id>/<task_id>", methods=["DELETE"])
376
+ def delete_task(proposal_id, task_id):
377
+ proposal = Proposal.load(proposal_id)
378
+ if not proposal:
379
+ return jsonify({"error": "Not found"}), 404
380
+
381
+ proposal.tasks = [t for t in proposal.tasks if t.get("id") != task_id]
382
+ proposal.budget_items = [b for b in proposal.budget_items if b.get("task_id") != task_id]
383
+ proposal.save()
384
+ return jsonify({"ok": True})
385
+
386
+ @app.route("/api/budget/<proposal_id>", methods=["POST"])
387
+ def add_budget_item(proposal_id):
388
+ proposal = Proposal.load(proposal_id)
389
+ if not proposal:
390
+ return jsonify({"error": "Not found"}), 404
391
+
392
+ data = request.get_json()
393
+ item = {
394
+ "id": __import__("uuid").uuid4().hex[:8],
395
+ "task_id": data.get("task_id", ""),
396
+ "name": data.get("name", ""),
397
+ "cost_per_unit": float(data.get("cost_per_unit", 0)),
398
+ "units": float(data.get("units", 1)),
399
+ }
400
+ proposal.budget_items.append(item)
401
+ proposal.save()
402
+ return jsonify(item), 201
403
+
404
+ @app.route("/api/budget/<proposal_id>/<item_id>", methods=["DELETE"])
405
+ def delete_budget_item(proposal_id, item_id):
406
+ proposal = Proposal.load(proposal_id)
407
+ if not proposal:
408
+ return jsonify({"error": "Not found"}), 404
409
+
410
+ proposal.budget_items = [b for b in proposal.budget_items if b.get("id") != item_id]
411
+ proposal.save()
412
+ return jsonify({"ok": True})
413
+
414
+ @app.route("/api/budget/<proposal_id>/<item_id>", methods=["PUT"])
415
+ def update_budget_item(proposal_id, item_id):
416
+ proposal = Proposal.load(proposal_id)
417
+ if not proposal:
418
+ return jsonify({"error": "Not found"}), 404
419
+
420
+ data = request.get_json()
421
+ if not data:
422
+ return jsonify({"error": "No data"}), 400
423
+
424
+ for item in proposal.budget_items:
425
+ if item.get("id") == item_id:
426
+ item["task_id"] = data.get("task_id", item.get("task_id", ""))
427
+ item["name"] = data.get("name", item.get("name", ""))
428
+ item["cost_per_unit"] = float(data.get("cost_per_unit", item.get("cost_per_unit", 0)))
429
+ item["units"] = float(data.get("units", item.get("units", 1)))
430
+ break
431
+ else:
432
+ return jsonify({"error": "Item not found"}), 404
433
+
434
+ proposal.save()
435
+ return jsonify({"ok": True})
436
+
437
+ return app
438
+
439
+
440
+ def run_server():
441
+ app = create_app()
442
+ app.run(debug=True, port=5000)
443
+
444
+
445
+ if __name__ == "__main__":
446
+ run_server()
app/models.py ADDED
@@ -0,0 +1,115 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass, field, asdict
4
+ from typing import Any
5
+ from datetime import datetime
6
+ import uuid
7
+
8
+
9
+ PROPOSALS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "proposals")
10
+
11
+
12
+ def ensure_dirs():
13
+ os.makedirs(PROPOSALS_DIR, exist_ok=True)
14
+
15
+
16
+ @dataclass
17
+ class Task:
18
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
19
+ name: str = ""
20
+ description: str = ""
21
+ budget_items: list = field(default_factory=list)
22
+ lead_months: int = 0
23
+ duration_months: int = 1
24
+
25
+
26
+ @dataclass
27
+ class BudgetItem:
28
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
29
+ task_id: str = ""
30
+ name: str = ""
31
+ cost_per_unit: float = 0.0
32
+ units: float = 1.0
33
+
34
+ @property
35
+ def total_cost(self) -> float:
36
+ return self.cost_per_unit * self.units
37
+
38
+
39
+ @dataclass
40
+ class Proposal:
41
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
42
+ title: str = "Untitled Proposal"
43
+ client_name: str = ""
44
+ created_at: str = field(default_factory=lambda: datetime.now().isoformat())
45
+ updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
46
+
47
+ project_summary: str = ""
48
+ tasks: list = field(default_factory=list)
49
+ qualifications: str = ""
50
+
51
+ budget_items: list = field(default_factory=list)
52
+ budget_item_timings: dict = field(default_factory=dict)
53
+ start_date: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d"))
54
+ indirect_percent: float = 0.0
55
+ timeline_use_days: bool = False
56
+ timeline_show_budget: bool = False
57
+
58
+ def to_dict(self) -> dict:
59
+ return asdict(self)
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: dict) -> "Proposal":
63
+ return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
64
+
65
+ def save(self):
66
+ ensure_dirs()
67
+ self.updated_at = datetime.now().isoformat()
68
+ filepath = os.path.join(PROPOSALS_DIR, f"{self.id}.json")
69
+ with open(filepath, "w") as f:
70
+ json.dump(self.to_dict(), f, indent=2)
71
+
72
+ @classmethod
73
+ def load(cls, proposal_id: str) -> "Proposal | None":
74
+ filepath = os.path.join(PROPOSALS_DIR, f"{proposal_id}.json")
75
+ if not os.path.exists(filepath):
76
+ return None
77
+ try:
78
+ with open(filepath, "r") as f:
79
+ return cls.from_dict(json.load(f))
80
+ except (json.JSONDecodeError, OSError):
81
+ return None
82
+
83
+ @classmethod
84
+ def list_all(cls) -> list:
85
+ ensure_dirs()
86
+ proposals = []
87
+ for filename in sorted(os.listdir(PROPOSALS_DIR)):
88
+ if filename.endswith(".json"):
89
+ try:
90
+ with open(os.path.join(PROPOSALS_DIR, filename), "r") as f:
91
+ data = json.load(f)
92
+ proposals.append({
93
+ "id": data.get("id", filename.replace(".json", "")),
94
+ "title": data.get("title", "Untitled"),
95
+ "client_name": data.get("client_name", ""),
96
+ "updated_at": data.get("updated_at", ""),
97
+ })
98
+ except (json.JSONDecodeError, OSError):
99
+ continue
100
+ return sorted(proposals, key=lambda x: x["updated_at"], reverse=True)
101
+
102
+ @classmethod
103
+ def delete(cls, proposal_id: str) -> bool:
104
+ filepath = os.path.join(PROPOSALS_DIR, f"{proposal_id}.json")
105
+ if os.path.exists(filepath):
106
+ os.remove(filepath)
107
+ return True
108
+ return False
109
+
110
+ @property
111
+ def total_budget(self) -> float:
112
+ return sum(
113
+ item.get("cost_per_unit", 0) * item.get("units", 0)
114
+ for item in self.budget_items
115
+ )
app/snippets.py ADDED
@@ -0,0 +1,135 @@
1
+ import json
2
+ import os
3
+ from flask import Blueprint, render_template, request, jsonify
4
+
5
+ snippets_bp = Blueprint("snippets", __name__)
6
+
7
+ SNIPPETS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "snippets")
8
+ CUSTOM_DIR = os.path.join(SNIPPETS_DIR, "custom")
9
+
10
+
11
+ def ensure_dirs():
12
+ os.makedirs(SNIPPETS_DIR, exist_ok=True)
13
+ os.makedirs(CUSTOM_DIR, exist_ok=True)
14
+
15
+
16
+ def load_snippets(filename):
17
+ filepath = os.path.join(SNIPPETS_DIR, filename)
18
+ if os.path.exists(filepath):
19
+ with open(filepath, "r") as f:
20
+ return json.load(f)
21
+ return []
22
+
23
+
24
+ def save_snippets(filename, data):
25
+ ensure_dirs()
26
+ filepath = os.path.join(SNIPPETS_DIR, filename)
27
+ with open(filepath, "w") as f:
28
+ json.dump(data, f, indent=2)
29
+
30
+
31
+ def load_custom_snippets():
32
+ ensure_dirs()
33
+ snippets = []
34
+ for filename in sorted(os.listdir(CUSTOM_DIR)):
35
+ if filename.endswith(".json"):
36
+ with open(os.path.join(CUSTOM_DIR, filename), "r") as f:
37
+ snippets.append(json.load(f))
38
+ return snippets
39
+
40
+
41
+ @snippets_bp.route("/snippets")
42
+ def get_all_snippets():
43
+ return jsonify({
44
+ "organization": load_snippets("organization.json"),
45
+ "deliverables": load_snippets("deliverables.json"),
46
+ "custom": load_custom_snippets(),
47
+ })
48
+
49
+
50
+ @snippets_bp.route("/snippets/<category>", methods=["POST"])
51
+ def add_snippet(category):
52
+ data = request.get_json()
53
+ if not data or "title" not in data or "content" not in data:
54
+ return jsonify({"error": "title and content required"}), 400
55
+
56
+ snippet = {
57
+ "id": data.get("id", __import__("uuid").uuid4().hex[:8]),
58
+ "title": data["title"],
59
+ "content": data["content"],
60
+ "category": category,
61
+ }
62
+
63
+ if category == "custom":
64
+ ensure_dirs()
65
+ filepath = os.path.join(CUSTOM_DIR, f"{snippet['id']}.json")
66
+ with open(filepath, "w") as f:
67
+ json.dump(snippet, f, indent=2)
68
+ elif category in ("organization", "deliverables"):
69
+ snippets = load_snippets(f"{category}.json")
70
+ snippets.append(snippet)
71
+ save_snippets(f"{category}.json", snippets)
72
+ else:
73
+ return jsonify({"error": "Invalid category"}), 400
74
+
75
+ return jsonify(snippet), 201
76
+
77
+
78
+ @snippets_bp.route("/snippets/<category>/<snippet_id>", methods=["DELETE"])
79
+ def delete_snippet(category, snippet_id):
80
+ if category == "custom":
81
+ filepath = os.path.join(CUSTOM_DIR, f"{snippet_id}.json")
82
+ if os.path.exists(filepath):
83
+ os.remove(filepath)
84
+ return jsonify({"ok": True})
85
+ elif category in ("organization", "deliverables"):
86
+ snippets = load_snippets(f"{category}.json")
87
+ snippets = [s for s in snippets if s.get("id") != snippet_id]
88
+ save_snippets(f"{category}.json", snippets)
89
+ return jsonify({"ok": True})
90
+
91
+ return jsonify({"error": "Not found"}), 404
92
+
93
+
94
+ @snippets_bp.route("/snippets/import", methods=["POST"])
95
+ def import_snippet():
96
+ if "file" not in request.files:
97
+ return jsonify({"error": "No file provided"}), 400
98
+
99
+ file = request.files["file"]
100
+ if not file.filename:
101
+ return jsonify({"error": "No file selected"}), 400
102
+
103
+ filename = file.filename.lower()
104
+ title = request.form.get("title", "").strip()
105
+ if not title:
106
+ title = os.path.splitext(file.filename)[0]
107
+
108
+ try:
109
+ if filename.endswith(".md") or filename.endswith(".markdown") or filename.endswith(".txt"):
110
+ content = file.read().decode("utf-8")
111
+ elif filename.endswith(".docx"):
112
+ from docx import Document
113
+ doc = Document(file)
114
+ content = "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
115
+ else:
116
+ return jsonify({"error": "Unsupported file type. Use .md, .txt, or .docx"}), 400
117
+ except Exception as e:
118
+ return jsonify({"error": f"Failed to read file: {str(e)}"}), 400
119
+
120
+ if not content.strip():
121
+ return jsonify({"error": "File is empty"}), 400
122
+
123
+ snippet = {
124
+ "id": __import__("uuid").uuid4().hex[:8],
125
+ "title": title,
126
+ "content": content,
127
+ "category": "custom",
128
+ }
129
+
130
+ ensure_dirs()
131
+ filepath = os.path.join(CUSTOM_DIR, f"{snippet['id']}.json")
132
+ with open(filepath, "w") as f:
133
+ json.dump(snippet, f, indent=2)
134
+
135
+ return jsonify(snippet), 201
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: propongo2
3
+ Version: 0.1.1
4
+ Summary: A local proposal generator for conservation and natural resource projects
5
+ Author: VR Conservation
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/VRConservation/propongo2
8
+ Keywords: proposal,generator,conservation,budget,scope
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Office/Business
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: flask>=3.0
20
+ Requires-Dist: weasyprint>=60.0
21
+ Requires-Dist: markdown>=3.5
22
+ Requires-Dist: python-docx>=1.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Requires-Dist: bump2version>=1.0; extra == "dev"
26
+ Requires-Dist: twine>=5.0; extra == "dev"
27
+ Requires-Dist: build>=1.0; extra == "dev"
28
+
29
+ # Propongo 2
30
+
31
+ A local proposal generator for conservation and natural resource projects. Create professional proposals with scope of work, budgets, qualifications, timelines, and export to PDF or HTML.
32
+
33
+ ## Features
34
+
35
+ - **Scope of Work** - Define project summary, tasks, and deliverables
36
+ - **Budget** - Line-item budgeting with cost/unit calculations and totals
37
+ - **Qualifications** - Document team background and relevant experience
38
+ - **Timeline** - Set lead times and durations per task with Gantt chart visualization
39
+ - **Preview** - View the complete proposal before exporting
40
+ - **PDF Export** - Clean, professional PDF output via WeasyPrint
41
+ - **HTML Export** - Standalone HTML file with embedded styles
42
+ - **Snippet Library** - Reusable markdown components for organization descriptions, deliverable templates, and custom content
43
+ - **Save/Load** - Proposals stored as JSON files on disk, no database required
44
+
45
+ ## Quick Start
46
+
47
+ ### Install from PyPI
48
+
49
+ ```bash
50
+ pip install propongo2
51
+ ```
52
+
53
+ ### Run
54
+
55
+ ```bash
56
+ propongo2
57
+ ```
58
+
59
+ Opens at [http://localhost:5000](http://localhost:5000)
60
+
61
+ ### Install from source
62
+
63
+ ```bash
64
+ git clone https://github.com/VRConservation/propongo2.git
65
+ cd propongo2
66
+ pip install -e .
67
+ propongo2
68
+ ```
69
+
70
+ ### Development setup
71
+
72
+ ```bash
73
+ git clone https://github.com/VRConservation/propongo2.git
74
+ cd propongo2
75
+ pip install -e ".[dev]"
76
+ python run.py
77
+ ```
78
+
79
+ ## Usage
80
+
81
+ ### Creating a Proposal
82
+
83
+ 1. Click **New Proposal** from the dashboard
84
+ 2. Enter a title and optional client name in the header
85
+ 3. Work through each tab:
86
+
87
+ **Scope** - Add a project summary, then create tasks/deliverables with descriptions
88
+
89
+ **Budget** - Select a task, enter line items with cost per unit and quantities. Totals calculate automatically.
90
+
91
+ **Qualifications** - Describe your organization's background and why you're qualified for this project
92
+
93
+ **Timeline** - Set project start date, configure lead times and durations per task, then view the Gantt chart
94
+
95
+ **Preview** - Review the complete proposal before exporting
96
+
97
+ ### Using Snippets
98
+
99
+ Click the sidebar icon (&#9776;) to open the snippet library:
100
+
101
+ - **Organization** - Pre-written organization descriptions
102
+ - **Deliverables** - Templates for common deliverable types (surveys, assessments, plans)
103
+ - **Custom** - Create and save your own reusable snippets
104
+
105
+ Click a snippet to insert it at the cursor position in any text field.
106
+
107
+ ### Exporting
108
+
109
+ - **PDF** - Click "Export PDF" to generate a clean, professional PDF document
110
+ - **HTML** - Click "Export HTML" to download a standalone HTML file
111
+ - **Print** - Use Ctrl+P / Cmd+P in the Preview tab for browser printing
112
+
113
+ ### Managing Proposals
114
+
115
+ - Proposals auto-save as you work
116
+ - Click "Proposals" in the header to see all saved proposals
117
+ - Create, edit, or delete proposals from the dashboard
118
+
119
+ ## Updating
120
+
121
+ ### From PyPI
122
+
123
+ ```bash
124
+ pip install --upgrade propongo2
125
+ ```
126
+
127
+ ### From source
128
+
129
+ ```bash
130
+ cd propongo2
131
+ git pull
132
+ pip install -e .
133
+ ```
134
+
135
+ ## Development
136
+
137
+ ### Run tests
138
+
139
+ ```bash
140
+ pytest
141
+ ```
142
+
143
+ ### Bump version
144
+
145
+ ```bash
146
+ bump2version patch # 0.1.0 -> 0.1.1
147
+ bump2version minor # 0.1.1 -> 0.2.0
148
+ bump2version major # 0.2.0 -> 1.0.0
149
+ ```
150
+
151
+ ### Publish to PyPI
152
+
153
+ ```bash
154
+ python -m build
155
+ twine upload dist/*
156
+ ```
157
+
158
+ ## Project Structure
159
+
160
+ ```
161
+ propongo2/
162
+ ├── run.py # Dev entry point
163
+ ├── pyproject.toml # Package config
164
+ ├── requirements.txt # Dependencies
165
+ ├── app/
166
+ │ ├── __init__.py # Version
167
+ │ ├── main.py # Flask app + routes
168
+ │ ├── models.py # Proposal data model
169
+ │ ├── export.py # PDF/HTML export
170
+ │ ├── snippets.py # Snippet management
171
+ │ ├── templates/ # Jinja2 templates
172
+ │ │ ├── base.html # Layout + HTMX
173
+ │ │ ├── index.html # Proposal dashboard
174
+ │ │ ├── scope.html # Scope editor
175
+ │ │ ├── budget.html # Budget editor
176
+ │ │ ├── qualifications.html # Qualifications editor
177
+ │ │ ├── timeline.html # Timeline + Gantt
178
+ │ │ ├── preview.html # Proposal preview
179
+ │ │ └── export_proposal.html# PDF export template
180
+ │ ├── static/
181
+ │ │ ├── css/style.css # All styles
182
+ │ │ └── js/
183
+ │ │ ├── app.js # Core JS + HTMX helpers
184
+ │ │ ├── budget.js # Budget calculations
185
+ │ │ ├── gantt.js # Gantt chart rendering
186
+ │ │ └── snippets.js # Snippet panel logic
187
+ │ ├── snippets/ # Stock snippet data
188
+ │ │ ├── organization.json
189
+ │ │ ├── deliverables.json
190
+ │ │ └── custom/ # User-created snippets
191
+ │ └── data/
192
+ │ └── proposals/ # Saved proposals (JSON)
193
+ └── tests/
194
+ ├── test_main.py
195
+ └── test_export.py
196
+ ```
197
+
198
+ ## Tech Stack
199
+
200
+ - **Backend:** Python, Flask
201
+ - **Frontend:** HTMX, Jinja2, vanilla CSS/JS
202
+ - **PDF Export:** WeasyPrint
203
+ - **Packaging:** pyproject.toml, setuptools
204
+
205
+ ## License
206
+
207
+ MIT
@@ -0,0 +1,10 @@
1
+ app/__init__.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
2
+ app/export.py,sha256=huJVrU94Unz3SVt4_-PkpXfCqw-dMl51BYZXzHo_he0,3369
3
+ app/main.py,sha256=Hv3lD3YH3lAls880HYoxX61-WpBnTgD0Xy6SruKOj4o,16696
4
+ app/models.py,sha256=Rm6yO3I8aVldCpf5rpiAam5Sr1uQE1IDk5DqSQ_7MR4,3717
5
+ app/snippets.py,sha256=xqpeBCdXgzoQF8-f6DoesKMx7j9R3-_zVJFkMAfnNWg,4326
6
+ propongo2-0.1.1.dist-info/METADATA,sha256=i5m7E57y_S4XCsnTafjyh5u7u6htM92dm5TsuEK2XRI,6214
7
+ propongo2-0.1.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
8
+ propongo2-0.1.1.dist-info/entry_points.txt,sha256=o8nCinh0OGxLIha2dFmQwaNyB_3sUMv-PRgr5eUYlWM,50
9
+ propongo2-0.1.1.dist-info/top_level.txt,sha256=io9g7LCbfmTG1SFKgEOGXmCFB9uMP2H5lerm0HiHWQE,4
10
+ propongo2-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ propongo2 = app.main:run_server
@@ -0,0 +1 @@
1
+ app