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 +1 -0
- app/export.py +104 -0
- app/main.py +446 -0
- app/models.py +115 -0
- app/snippets.py +135 -0
- propongo2-0.1.1.dist-info/METADATA +207 -0
- propongo2-0.1.1.dist-info/RECORD +10 -0
- propongo2-0.1.1.dist-info/WHEEL +5 -0
- propongo2-0.1.1.dist-info/entry_points.txt +2 -0
- propongo2-0.1.1.dist-info/top_level.txt +1 -0
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 (☰) 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 @@
|
|
|
1
|
+
app
|