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/models.py ADDED
@@ -0,0 +1,201 @@
1
+ """Data models for Propongo.
2
+
3
+ Tasks and budget items are stored as dictionaries with the following structure:
4
+
5
+ Task dict:
6
+ {
7
+ 'id': str, # UUID
8
+ 'name': str, # Task name
9
+ 'description': str, # Task description
10
+ 'lead_entity': str, # Organization responsible
11
+ 'start_month': int, # Start month (1-12)
12
+ 'start_year': int, # Start year
13
+ 'duration_months': int, # Duration in months
14
+ }
15
+
16
+ BudgetItem dict:
17
+ {
18
+ 'id': str, # UUID
19
+ 'task_id': str, # Associated task UUID
20
+ 'name': str, # Item name
21
+ 'cost_per_unit': float, # Unit cost
22
+ 'units': float, # Number of units
23
+ }
24
+
25
+ Custom Section dict:
26
+ {
27
+ 'id': str, # UUID
28
+ 'title': str, # Section title
29
+ 'content': str, # Markdown content
30
+ 'order': int, # Display order
31
+ }
32
+ """
33
+
34
+ import json
35
+ import logging
36
+ import os
37
+ import shutil
38
+ import sys
39
+ from dataclasses import dataclass, field, asdict
40
+ from typing import Optional
41
+ from datetime import datetime
42
+ import uuid
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ if sys.platform == "win32":
47
+ _DATA_ROOT = os.path.join(os.path.expanduser("~"), "Documents", "Propongo")
48
+ else:
49
+ _DATA_ROOT = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
50
+ PROPOSALS_DIR = os.path.join(_DATA_ROOT, "proposals")
51
+ TEMPLATES_DIR = os.path.join(_DATA_ROOT, "templates")
52
+
53
+
54
+ def _migrate_from_propongo2() -> None:
55
+ """Migrate user data from Propongo2 to Propongo on Windows."""
56
+ if sys.platform != "win32":
57
+ return
58
+ old_root = os.path.join(os.path.expanduser("~"), "Documents", "Propongo2")
59
+ if not os.path.isdir(old_root):
60
+ return
61
+ if os.path.isdir(_DATA_ROOT):
62
+ return
63
+ try:
64
+ shutil.copytree(old_root, _DATA_ROOT)
65
+ logger.info("Migrated data from %s to %s", old_root, _DATA_ROOT)
66
+ except OSError:
67
+ logger.warning("Could not migrate data from %s", old_root)
68
+
69
+
70
+ def ensure_dirs() -> None:
71
+ """Ensure data directories exist."""
72
+ _migrate_from_propongo2()
73
+ os.makedirs(PROPOSALS_DIR, exist_ok=True)
74
+ os.makedirs(TEMPLATES_DIR, exist_ok=True)
75
+
76
+
77
+ @dataclass
78
+ class Proposal:
79
+ """A project proposal containing tasks, budget items, and custom sections."""
80
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
81
+ title: str = "Untitled Proposal"
82
+ client_name: str = ""
83
+ subtitle: str = ""
84
+ created_at: str = field(default_factory=lambda: datetime.now().isoformat())
85
+ updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
86
+
87
+ project_summary: str = ""
88
+ scope: str = ""
89
+ tasks: list = field(default_factory=list)
90
+ qualifications: str = ""
91
+
92
+ budget_items: list = field(default_factory=list)
93
+ budget_item_timings: dict = field(default_factory=dict)
94
+ start_date: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d"))
95
+ end_date: str = ""
96
+ indirect_percent: float = 0.0
97
+ show_budget_description: bool = False
98
+ budget_description: str = ""
99
+ timeline_use_days: bool = False
100
+ timeline_show_budget: bool = False
101
+ custom_sections: list = field(default_factory=list)
102
+
103
+ is_template: bool = False
104
+ template_name: str = ""
105
+ template_category: str = ""
106
+
107
+ milestones: list = field(default_factory=list)
108
+ reports: list = field(default_factory=list)
109
+
110
+ def to_dict(self) -> dict:
111
+ """Serialize the proposal to a dictionary."""
112
+ return asdict(self)
113
+
114
+ @classmethod
115
+ def from_dict(cls, data: dict) -> "Proposal":
116
+ """Create a Proposal from a dictionary, ignoring unknown fields."""
117
+ return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
118
+
119
+ def save(self):
120
+ """Save the proposal to disk as a JSON file."""
121
+ ensure_dirs()
122
+ self.updated_at = datetime.now().isoformat()
123
+ target_dir = TEMPLATES_DIR if self.is_template else PROPOSALS_DIR
124
+ filepath = os.path.join(target_dir, f"{self.id}.json")
125
+ tmp = filepath + ".tmp"
126
+ with open(tmp, "w") as f:
127
+ json.dump(self.to_dict(), f, indent=2)
128
+ f.write("\n")
129
+ os.replace(tmp, filepath)
130
+
131
+ @classmethod
132
+ def load(cls, proposal_id: str, is_template: bool = False) -> Optional["Proposal"]:
133
+ """Load a proposal or template by ID from disk."""
134
+ target_dir = TEMPLATES_DIR if is_template else PROPOSALS_DIR
135
+ filepath = os.path.join(target_dir, f"{proposal_id}.json")
136
+ if not os.path.exists(filepath):
137
+ return None
138
+ try:
139
+ with open(filepath, "r") as f:
140
+ return cls.from_dict(json.load(f))
141
+ except (json.JSONDecodeError, OSError):
142
+ return None
143
+
144
+ @classmethod
145
+ def list_all(cls) -> list:
146
+ """List all proposals, returning summaries sorted by most recent."""
147
+ ensure_dirs()
148
+ proposals = []
149
+ for filename in sorted(os.listdir(PROPOSALS_DIR)):
150
+ if filename.endswith(".json"):
151
+ try:
152
+ with open(os.path.join(PROPOSALS_DIR, filename), "r") as f:
153
+ data = json.load(f)
154
+ proposals.append({
155
+ "id": data.get("id", filename.replace(".json", "")),
156
+ "title": data.get("title", "Untitled"),
157
+ "client_name": data.get("client_name", ""),
158
+ "updated_at": data.get("updated_at", ""),
159
+ })
160
+ except (json.JSONDecodeError, OSError):
161
+ continue
162
+ return sorted(proposals, key=lambda x: x["updated_at"], reverse=True)
163
+
164
+ @classmethod
165
+ def list_templates(cls) -> list:
166
+ """List all templates, returning summaries sorted by most recent."""
167
+ ensure_dirs()
168
+ templates = []
169
+ for filename in sorted(os.listdir(TEMPLATES_DIR)):
170
+ if filename.endswith(".json"):
171
+ try:
172
+ with open(os.path.join(TEMPLATES_DIR, filename), "r") as f:
173
+ data = json.load(f)
174
+ templates.append({
175
+ "id": data.get("id", filename.replace(".json", "")),
176
+ "title": data.get("title", "Untitled"),
177
+ "template_name": data.get("template_name", ""),
178
+ "template_category": data.get("template_category", ""),
179
+ "updated_at": data.get("updated_at", ""),
180
+ })
181
+ except (json.JSONDecodeError, OSError):
182
+ continue
183
+ return sorted(templates, key=lambda x: x["updated_at"], reverse=True)
184
+
185
+ @classmethod
186
+ def delete(cls, proposal_id: str, is_template: bool = False) -> bool:
187
+ """Delete a proposal or template by ID. Returns True if deleted."""
188
+ target_dir = TEMPLATES_DIR if is_template else PROPOSALS_DIR
189
+ filepath = os.path.join(target_dir, f"{proposal_id}.json")
190
+ if os.path.exists(filepath):
191
+ os.remove(filepath)
192
+ return True
193
+ return False
194
+
195
+ @property
196
+ def total_budget(self) -> float:
197
+ """Compute total budget as the sum of cost_per_unit * units for all items."""
198
+ return sum(
199
+ item.get("cost_per_unit", 0) * item.get("units", 0)
200
+ for item in self.budget_items
201
+ )
@@ -0,0 +1,32 @@
1
+ [
2
+ {
3
+ "id": "del-survey",
4
+ "title": "Field Survey Report",
5
+ "content": "## Field Survey Report\n\nA comprehensive field survey will be conducted to assess current conditions, identify key features, and document existing resources. The survey will include:\n\n- Site walkthrough and observation\n- Photo documentation of key areas\n- GPS mapping of boundaries and features\n- Data collection using standardized protocols\n- Analysis and written report with recommendations\n\n**Deliverable:** Written report with maps, photographs, and management recommendations.",
6
+ "category": "deliverables"
7
+ },
8
+ {
9
+ "id": "del-assessment",
10
+ "title": "Natural Resource Assessment",
11
+ "content": "## Natural Resource Assessment\n\nA detailed assessment of natural resources within the project area, including:\n\n- Inventory of plant and animal species\n- Habitat condition evaluation\n- Water quality and hydrology review\n- Soil analysis and erosion risk mapping\n- Comparison with regional benchmarks\n\n**Deliverable:** Technical report with data tables, maps, and condition ratings.",
12
+ "category": "deliverables"
13
+ },
14
+ {
15
+ "id": "del-management-plan",
16
+ "title": "Management Plan",
17
+ "content": "## Conservation Management Plan\n\nA comprehensive management plan outlining strategies and actions for long-term resource stewardship:\n\n- Current conditions summary\n- Goals and objectives\n- Recommended management actions\n- Implementation schedule\n- Monitoring and evaluation framework\n- Budget estimates for implementation\n\n**Deliverable:** Written plan with appendices, maps, and implementation timeline.",
18
+ "category": "deliverables"
19
+ },
20
+ {
21
+ "id": "del-monitoring",
22
+ "title": "Monitoring Protocol",
23
+ "content": "## Monitoring Protocol\n\nEstablishment of a monitoring program to track conservation outcomes:\n\n- Key performance indicators (KPIs)\n- Sampling design and methods\n- Data collection schedules\n- Reporting templates\n- Adaptive management triggers\n\n**Deliverable:** Monitoring protocol document with data sheets and reporting schedule.",
24
+ "category": "deliverables"
25
+ },
26
+ {
27
+ "id": "del-gis",
28
+ "title": "GIS Mapping Package",
29
+ "content": "## GIS Mapping Package\n\nDigital mapping products to support project planning and implementation:\n\n- Base maps with aerial imagery\n- Land cover/land use classification\n- Habitat type mapping\n- Topographic analysis\n- Project area boundary delineation\n\n**Deliverable:** GIS geodatabase, PDF maps, and web map viewer.",
30
+ "category": "deliverables"
31
+ }
32
+ ]
@@ -0,0 +1,26 @@
1
+ [
2
+ {
3
+ "id": "org-about",
4
+ "title": "About Our Organization",
5
+ "content": "VR Conservation is dedicated to the preservation and restoration of natural resources through science-based conservation practices. Our team combines decades of field experience with cutting-edge technology to deliver measurable conservation outcomes for communities, landowners, and government agencies.",
6
+ "category": "organization"
7
+ },
8
+ {
9
+ "id": "org-mission",
10
+ "title": "Mission Statement",
11
+ "content": "Our mission is to protect and restore critical ecosystems through collaborative conservation, research, and community engagement. We work alongside stakeholders to develop sustainable solutions that balance ecological integrity with community needs.",
12
+ "category": "organization"
13
+ },
14
+ {
15
+ "id": "org-experience",
16
+ "title": "Relevant Experience",
17
+ "content": "Our team has successfully completed over 50 conservation projects across the region, including watershed restoration, wildlife habitat improvement, and natural resource assessments. We maintain strong relationships with state and federal agencies, ensuring our work meets or exceeds regulatory standards.",
18
+ "category": "organization"
19
+ },
20
+ {
21
+ "id": "org-team",
22
+ "title": "Team Qualifications",
23
+ "content": "Our interdisciplinary team includes certified wildlife biologists, hydrologists, GIS specialists, and project managers. Each team member holds relevant certifications and brings a minimum of 10 years of field experience to every project.",
24
+ "category": "organization"
25
+ }
26
+ ]
app/snippets.py ADDED
@@ -0,0 +1,156 @@
1
+ """Snippet management for reusable text blocks."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import uuid
7
+ import logging
8
+ from typing import Tuple, Dict, List, Any
9
+ from flask import Blueprint, render_template, request, jsonify, Response
10
+ from .config import ERROR_MESSAGES
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ snippets_bp = Blueprint("snippets", __name__)
15
+
16
+ _PKG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "snippets")
17
+ if sys.platform == "win32":
18
+ SNIPPETS_DIR = os.path.join(os.path.expanduser("~"), "Documents", "Propongo", "snippets")
19
+ else:
20
+ SNIPPETS_DIR = _PKG_DIR
21
+ CUSTOM_DIR = os.path.join(SNIPPETS_DIR, "custom")
22
+
23
+
24
+ def ensure_dirs():
25
+ """Ensure snippet directories exist."""
26
+ os.makedirs(SNIPPETS_DIR, exist_ok=True)
27
+ os.makedirs(CUSTOM_DIR, exist_ok=True)
28
+
29
+
30
+ def load_snippets(filename):
31
+ """Load stock snippets from the package directory."""
32
+ filepath = os.path.join(_PKG_DIR, filename)
33
+ if os.path.exists(filepath):
34
+ with open(filepath, "r") as f:
35
+ return json.load(f)
36
+ return []
37
+
38
+
39
+ def save_snippets(filename, data):
40
+ """Save snippets to a JSON file."""
41
+ ensure_dirs()
42
+ filepath = os.path.join(SNIPPETS_DIR, filename)
43
+ with open(filepath, "w") as f:
44
+ json.dump(data, f, indent=2)
45
+
46
+
47
+ def load_custom_snippets():
48
+ """Load all user-created custom snippets."""
49
+ ensure_dirs()
50
+ snippets = []
51
+ for filename in sorted(os.listdir(CUSTOM_DIR)):
52
+ if filename.endswith(".json"):
53
+ with open(os.path.join(CUSTOM_DIR, filename), "r") as f:
54
+ snippets.append(json.load(f))
55
+ return snippets
56
+
57
+
58
+ @snippets_bp.route("/snippets")
59
+ def get_all_snippets():
60
+ """Return all snippets grouped by category."""
61
+ return jsonify({
62
+ "organization": load_snippets("organization.json"),
63
+ "deliverables": load_snippets("deliverables.json"),
64
+ "custom": load_custom_snippets(),
65
+ })
66
+
67
+
68
+ @snippets_bp.route("/snippets/<category>", methods=["POST"])
69
+ def add_snippet(category):
70
+ """Add a new snippet to the given category."""
71
+ data = request.get_json()
72
+ if not data or "title" not in data or "content" not in data:
73
+ return jsonify({"error": "title and content required"}), 400
74
+
75
+ snippet = {
76
+ "id": data.get("id", uuid.uuid4().hex[:8]),
77
+ "title": data["title"],
78
+ "content": data["content"],
79
+ "category": category,
80
+ }
81
+
82
+ if category == "custom":
83
+ ensure_dirs()
84
+ filepath = os.path.join(CUSTOM_DIR, f"{snippet['id']}.json")
85
+ with open(filepath, "w") as f:
86
+ json.dump(snippet, f, indent=2)
87
+ elif category in ("organization", "deliverables"):
88
+ snippets = load_snippets(f"{category}.json")
89
+ snippets.append(snippet)
90
+ save_snippets(f"{category}.json", snippets)
91
+ else:
92
+ return jsonify({"error": "Invalid category"}), 400
93
+
94
+ return jsonify(snippet), 201
95
+
96
+
97
+ @snippets_bp.route("/snippets/<category>/<snippet_id>", methods=["DELETE"])
98
+ def delete_snippet(category, snippet_id):
99
+ """Delete a snippet by category and ID."""
100
+ if category == "custom":
101
+ filepath = os.path.join(CUSTOM_DIR, f"{snippet_id}.json")
102
+ if os.path.exists(filepath):
103
+ os.remove(filepath)
104
+ return jsonify({"ok": True})
105
+ elif category in ("organization", "deliverables"):
106
+ snippets = load_snippets(f"{category}.json")
107
+ snippets = [s for s in snippets if s.get("id") != snippet_id]
108
+ save_snippets(f"{category}.json", snippets)
109
+ return jsonify({"ok": True})
110
+
111
+ return jsonify(ERROR_MESSAGES['SECTION_NOT_FOUND']), 404
112
+
113
+
114
+ @snippets_bp.route("/snippets/import", methods=["POST"])
115
+ def import_snippet():
116
+ """Import a snippet from a .md, .txt, or .docx file."""
117
+ if "file" not in request.files:
118
+ return jsonify(ERROR_MESSAGES['NO_FILE']), 400
119
+
120
+ file = request.files["file"]
121
+ if not file.filename:
122
+ return jsonify(ERROR_MESSAGES['NO_FILE']), 400
123
+
124
+ filename = file.filename.lower()
125
+ title = request.form.get("title", "").strip()
126
+ if not title:
127
+ title = os.path.splitext(file.filename)[0]
128
+
129
+ try:
130
+ if filename.endswith(".md") or filename.endswith(".markdown") or filename.endswith(".txt"):
131
+ content = file.read().decode("utf-8")
132
+ elif filename.endswith(".docx"):
133
+ from docx import Document
134
+ doc = Document(file)
135
+ content = "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
136
+ else:
137
+ return jsonify({"error": "Unsupported file type. Use .md, .txt, or .docx"}), 400
138
+ except Exception as e:
139
+ return jsonify({"error": f"Failed to read file: {str(e)}"}), 400
140
+
141
+ if not content.strip():
142
+ return jsonify({"error": "File is empty"}), 400
143
+
144
+ snippet = {
145
+ "id": uuid.uuid4().hex[:8],
146
+ "title": title,
147
+ "content": content,
148
+ "category": "custom",
149
+ }
150
+
151
+ ensure_dirs()
152
+ filepath = os.path.join(CUSTOM_DIR, f"{snippet['id']}.json")
153
+ with open(filepath, "w") as f:
154
+ json.dump(snippet, f, indent=2)
155
+
156
+ return jsonify(snippet), 201