propongo 1.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
app/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.3.4"
app/config.py ADDED
@@ -0,0 +1,43 @@
1
+ """Configuration settings for Propongo."""
2
+
3
+ import os
4
+ import sys
5
+
6
+
7
+ class Config:
8
+ """Application configuration."""
9
+
10
+ # Server settings
11
+ HOST = os.environ.get('HOST', '0.0.0.0')
12
+ PORT = int(os.environ.get('PORT', 5000))
13
+ DEBUG = os.environ.get('DEBUG', 'True').lower() in ('true', '1', 'yes')
14
+
15
+ # Security
16
+ SECRET_KEY = os.environ.get('FLASK_SECRET_KEY', os.urandom(24).hex())
17
+
18
+ # File paths
19
+ if sys.platform == "win32":
20
+ DATA_DIR = os.path.join(os.path.expanduser("~"), "Documents", "Propongo")
21
+ else:
22
+ DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data')
23
+ PROPOSALS_DIR = os.path.join(DATA_DIR, 'proposals')
24
+ EXPORTS_DIR = os.path.join(DATA_DIR, 'exports')
25
+
26
+ # Logging
27
+ LOG_LEVEL = os.environ.get('LOG_LEVEL', 'INFO')
28
+
29
+
30
+ # Error messages
31
+ ERROR_MESSAGES = {
32
+ 'PROPOSAL_NOT_FOUND': {'error': 'Proposal not found'},
33
+ 'SECTION_NOT_FOUND': {'error': 'Section not found'},
34
+ 'TASK_NOT_FOUND': {'error': 'Task not found'},
35
+ 'BUDGET_ITEM_NOT_FOUND': {'error': 'Budget item not found'},
36
+ 'NO_DATA': {'error': 'No data provided'},
37
+ 'NO_FILE': {'error': 'No file provided'},
38
+ 'INVALID_FILE_TYPE': {'error': 'Invalid file type'},
39
+ 'INVALID_NUMERIC': {'error': 'Invalid numeric value'},
40
+ 'EXCEL_NOT_INSTALLED': {'error': 'Excel support not installed. Install pandas and openpyxl.'},
41
+ 'EXCEL_INVALID_FILE': {'error': 'Invalid Excel file'},
42
+ 'EXCEL_PROCESSING_ERROR': {'error': 'Failed to process Excel file'},
43
+ }
app/export.py ADDED
@@ -0,0 +1,484 @@
1
+ """Export functionality for PDF, HTML, and DOCX generation."""
2
+
3
+ import os
4
+ import logging
5
+ import re as _re
6
+ from io import BytesIO
7
+ from typing import Tuple
8
+ try:
9
+ from weasyprint import HTML
10
+ except (OSError, ImportError):
11
+ HTML = None
12
+
13
+ GTK3_MISSING_MSG = (
14
+ "PDF export requires GTK3. Download the installer: "
15
+ "https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer/"
16
+ "releases/download/2022-01-04/gtk3-runtime-3.24.31-2022-01-04-ts-win64.exe"
17
+ )
18
+ from flask import Blueprint, render_template, request, send_file, jsonify, Response
19
+ from markupsafe import Markup
20
+ from .models import Proposal
21
+ from .utils import build_export_context, build_tracker_export_context
22
+ from .config import Config, ERROR_MESSAGES
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ export_bp = Blueprint("export", __name__)
27
+
28
+ EXPORT_DIR = Config.EXPORTS_DIR
29
+
30
+
31
+ def ensure_export_dir() -> None:
32
+ """Ensure export directory exists."""
33
+ os.makedirs(EXPORT_DIR, exist_ok=True)
34
+
35
+
36
+ def _md_to_plain(text: str) -> str:
37
+ """Strip markdown to plain text for DOCX."""
38
+ if not text:
39
+ return ""
40
+ text = _re.sub(r'#{1,6}\s+', '', text)
41
+ text = _re.sub(r'\*\*(.+?)\*\*', r'\1', text)
42
+ text = _re.sub(r'\*(.+?)\*', r'\1', text)
43
+ text = _re.sub(r'`(.+?)`', r'\1', text)
44
+ text = _re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
45
+ text = _re.sub(r'^[-*]\s+', ' - ', text, flags=_re.MULTILINE)
46
+ return text.strip()
47
+
48
+
49
+ def _build_proposal_docx(proposal) -> BytesIO:
50
+ """Build a DOCX document for a proposal."""
51
+ from docx import Document
52
+ from docx.shared import Inches, Pt, Cm, RGBColor
53
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
54
+ from docx.enum.table import WD_TABLE_ALIGNMENT
55
+
56
+ doc = Document()
57
+
58
+ style = doc.styles['Normal']
59
+ style.font.name = 'Calibri'
60
+ style.font.size = Pt(11)
61
+ style.paragraph_format.space_after = Pt(6)
62
+ style.paragraph_format.line_spacing = 1.15
63
+
64
+ for section in doc.sections:
65
+ section.top_margin = Cm(2.5)
66
+ section.bottom_margin = Cm(2.5)
67
+ section.left_margin = Cm(2.5)
68
+ section.right_margin = Cm(2.5)
69
+
70
+ p = doc.add_paragraph()
71
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
72
+ run = p.add_run(proposal.title or "Proposal")
73
+ run.bold = True
74
+ run.font.size = Pt(22)
75
+ run.font.color.rgb = RGBColor(0x1e, 0x3a, 0x5f)
76
+
77
+ if proposal.client_name:
78
+ p = doc.add_paragraph()
79
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
80
+ run = p.add_run(f"Funder: {proposal.client_name}")
81
+ run.font.size = Pt(12)
82
+ run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
83
+
84
+ if proposal.subtitle:
85
+ p = doc.add_paragraph()
86
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
87
+ run = p.add_run(f"Program: {proposal.subtitle}")
88
+ run.font.size = Pt(12)
89
+ run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
90
+
91
+ p = doc.add_paragraph()
92
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
93
+ run = p.add_run(proposal.updated_at[:10] if proposal.updated_at else "")
94
+ run.font.size = Pt(10)
95
+ run.font.color.rgb = RGBColor(0x99, 0x99, 0x99)
96
+
97
+ doc.add_paragraph()
98
+
99
+ if proposal.project_summary:
100
+ doc.add_heading('Project Summary', level=1)
101
+ for line in _md_to_plain(proposal.project_summary).split('\n'):
102
+ if line.strip():
103
+ doc.add_paragraph(line.strip())
104
+
105
+ if proposal.scope:
106
+ doc.add_heading('Scope', level=1)
107
+ for line in _md_to_plain(proposal.scope).split('\n'):
108
+ if line.strip():
109
+ doc.add_paragraph(line.strip())
110
+
111
+ if proposal.budget_items:
112
+ doc.add_heading('Budget', level=1)
113
+ table = doc.add_table(rows=1, cols=4)
114
+ table.style = 'Light Grid Accent 1'
115
+ table.alignment = WD_TABLE_ALIGNMENT.CENTER
116
+ headers = ['Item', 'Cost/Unit', 'Units', 'Total']
117
+ for i, h in enumerate(headers):
118
+ cell = table.rows[0].cells[i]
119
+ cell.text = h
120
+ for paragraph in cell.paragraphs:
121
+ for run in paragraph.runs:
122
+ run.bold = True
123
+ run.font.size = Pt(10)
124
+
125
+ for task in proposal.tasks:
126
+ task_items = [b for b in proposal.budget_items if b.get("task_id") == task["id"]]
127
+ if not task_items:
128
+ continue
129
+ task_total = sum(i.get("cost_per_unit", 0) * i.get("units", 0) for i in task_items)
130
+ row = table.add_row()
131
+ row.cells[0].text = task["name"]
132
+ row.cells[3].text = f"{task_total:,.0f}"
133
+ for cell in row.cells:
134
+ for paragraph in cell.paragraphs:
135
+ for run in paragraph.runs:
136
+ run.bold = True
137
+ run.font.size = Pt(10)
138
+ for item in task_items:
139
+ total = item.get("cost_per_unit", 0) * item.get("units", 0)
140
+ row = table.add_row()
141
+ row.cells[0].text = f" {item['name']}"
142
+ row.cells[1].text = f"{item.get('cost_per_unit', 0):,.0f}"
143
+ row.cells[2].text = f"{int(item.get('units', 0))}"
144
+ row.cells[3].text = f"{total:,.0f}"
145
+ for cell in row.cells:
146
+ for paragraph in cell.paragraphs:
147
+ for run in paragraph.runs:
148
+ run.font.size = Pt(10)
149
+
150
+ row = table.add_row()
151
+ row.cells[0].text = 'Subtotal'
152
+ row.cells[3].text = f"{proposal.total_budget:,.0f}"
153
+ for cell in row.cells:
154
+ for paragraph in cell.paragraphs:
155
+ for run in paragraph.runs:
156
+ run.bold = True
157
+
158
+ indirect_percent = getattr(proposal, 'indirect_percent', 0) or 0
159
+ if indirect_percent > 0:
160
+ indirect_amount = proposal.total_budget * (indirect_percent / 100)
161
+ row = table.add_row()
162
+ row.cells[0].text = f"Indirect ({indirect_percent:.0f}%)"
163
+ row.cells[3].text = f"{indirect_amount:,.0f}"
164
+
165
+ row = table.add_row()
166
+ row.cells[0].text = 'Total'
167
+ indirect_amount = proposal.total_budget * (indirect_percent / 100)
168
+ row.cells[3].text = f"${proposal.total_budget + indirect_amount:,.0f}"
169
+ for cell in row.cells:
170
+ for paragraph in cell.paragraphs:
171
+ for run in paragraph.runs:
172
+ run.bold = True
173
+
174
+ if proposal.show_budget_description and proposal.budget_description:
175
+ doc.add_heading('Budget Description', level=1)
176
+ for line in _md_to_plain(proposal.budget_description).split('\n'):
177
+ if line.strip():
178
+ doc.add_paragraph(line.strip())
179
+
180
+ if proposal.qualifications:
181
+ doc.add_heading('Qualifications', level=1)
182
+ for line in _md_to_plain(proposal.qualifications).split('\n'):
183
+ if line.strip():
184
+ doc.add_paragraph(line.strip())
185
+
186
+ if proposal.custom_sections:
187
+ for section in sorted(proposal.custom_sections, key=lambda s: s.get("order", 0)):
188
+ doc.add_heading(section.get("title", "Section"), level=1)
189
+ for line in _md_to_plain(section.get("content", "")).split('\n'):
190
+ if line.strip():
191
+ doc.add_paragraph(line.strip())
192
+
193
+ buf = BytesIO()
194
+ doc.save(buf)
195
+ buf.seek(0)
196
+ return buf
197
+
198
+
199
+ def _build_tracker_docx(proposal, ctx) -> BytesIO:
200
+ """Build a DOCX document for the project tracker."""
201
+ from docx import Document
202
+ from docx.shared import Pt, Cm, RGBColor
203
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
204
+ from docx.enum.table import WD_TABLE_ALIGNMENT
205
+
206
+ doc = Document()
207
+ style = doc.styles['Normal']
208
+ style.font.name = 'Calibri'
209
+ style.font.size = Pt(11)
210
+ style.paragraph_format.space_after = Pt(4)
211
+ style.paragraph_format.line_spacing = 1.15
212
+
213
+ for section in doc.sections:
214
+ section.top_margin = Cm(2.5)
215
+ section.bottom_margin = Cm(2.5)
216
+ section.left_margin = Cm(2.5)
217
+ section.right_margin = Cm(2.5)
218
+
219
+ p = doc.add_paragraph()
220
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
221
+ run = p.add_run(f"Project Report: {proposal.title}")
222
+ run.bold = True
223
+ run.font.size = Pt(20)
224
+ run.font.color.rgb = RGBColor(0x1e, 0x3a, 0x5f)
225
+
226
+ if proposal.client_name:
227
+ p = doc.add_paragraph()
228
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
229
+ run = p.add_run(f"{proposal.client_name}{(' - ' + proposal.subtitle) if proposal.subtitle else ''}")
230
+ run.font.size = Pt(12)
231
+ run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
232
+
233
+ doc.add_paragraph()
234
+
235
+ doc.add_heading('Dashboard Summary', level=1)
236
+ table = doc.add_table(rows=2, cols=4)
237
+ table.style = 'Light Grid Accent 1'
238
+ table.alignment = WD_TABLE_ALIGNMENT.CENTER
239
+ headers = ['Tasks Completed', 'Total Tasks', 'Total Budget', 'Milestones']
240
+ values = [f"{ctx['overall_pct']}%", str(len(ctx['tasks'])), f"${ctx['total_with_indirect']:,.0f}", str(len(ctx['milestones']))]
241
+ for i, h in enumerate(headers):
242
+ table.rows[0].cells[i].text = h
243
+ for run in table.rows[0].cells[i].paragraphs[0].runs:
244
+ run.bold = True
245
+ run.font.size = Pt(10)
246
+ for i, v in enumerate(values):
247
+ table.rows[1].cells[i].text = v
248
+ for run in table.rows[1].cells[i].paragraphs[0].runs:
249
+ run.font.size = Pt(10)
250
+
251
+ status_labels = {
252
+ "not_started": "Not Started",
253
+ "in_progress": "In Progress",
254
+ "completed": "Completed",
255
+ "delayed": "Delayed",
256
+ }
257
+
258
+ doc.add_heading('Tasks', level=1)
259
+ for task in ctx['tasks']:
260
+ doc.add_heading(task.get('name', 'Untitled'), level=2)
261
+ status = status_labels.get(task.get('status', 'not_started'), 'Not Started')
262
+ progress = task.get('progress_pct', 0)
263
+ p = doc.add_paragraph()
264
+ run = p.add_run(f"Status: {status}")
265
+ run.bold = True
266
+ p = doc.add_paragraph(f"Progress: {progress}%")
267
+ if task.get('actual_start'):
268
+ p = doc.add_paragraph(f"Actual Start: {task['actual_start']}")
269
+ if task.get('actual_end'):
270
+ p = doc.add_paragraph(f"Actual End: {task['actual_end']}")
271
+ if task.get('notes'):
272
+ doc.add_paragraph()
273
+ run = p.add_run("Notes:")
274
+ run.bold = True
275
+ for line in task['notes'].split('\n'):
276
+ if line.strip():
277
+ doc.add_paragraph(line.strip())
278
+
279
+ doc.add_heading('Budget Tracking', level=1)
280
+ table = doc.add_table(rows=1, cols=5)
281
+ table.style = 'Light Grid Accent 1'
282
+ table.alignment = WD_TABLE_ALIGNMENT.CENTER
283
+ headers = ['Task', 'Item', 'Planned', 'Actual', 'Variance']
284
+ for i, h in enumerate(headers):
285
+ table.rows[0].cells[i].text = h
286
+ for run in table.rows[0].cells[i].paragraphs[0].runs:
287
+ run.bold = True
288
+ run.font.size = Pt(9)
289
+
290
+ for task in ctx['tasks']:
291
+ tid = task.get("id", "")
292
+ if tid not in ctx['task_budgets']:
293
+ continue
294
+ tb = ctx['task_budgets'][tid]
295
+ row = table.add_row()
296
+ row.cells[0].text = task.get('name', '')
297
+ row.cells[2].text = f"${tb['subtotal']:,.0f}"
298
+ row.cells[3].text = f"${tb['actual_total']:,.0f}"
299
+ variance = tb['actual_total'] - tb['subtotal']
300
+ row.cells[4].text = f"${variance:,.0f}"
301
+ for cell in row.cells:
302
+ for run in cell.paragraphs[0].runs:
303
+ run.bold = True
304
+ run.font.size = Pt(9)
305
+ for item in tb["items"]:
306
+ planned = item.get("cost_per_unit", 0) * item.get("units", 0)
307
+ actual = item.get("actual_cost", 0)
308
+ v = actual - planned
309
+ row = table.add_row()
310
+ row.cells[1].text = item.get('name', '')
311
+ row.cells[2].text = f"${planned:,.0f}"
312
+ row.cells[3].text = f"${actual:,.0f}"
313
+ row.cells[4].text = f"${v:,.0f}"
314
+ for cell in row.cells:
315
+ for run in cell.paragraphs[0].runs:
316
+ run.font.size = Pt(9)
317
+
318
+ row = table.add_row()
319
+ row.cells[0].text = 'Total'
320
+ row.cells[2].text = f"${ctx['total_with_indirect']:,.0f}"
321
+ row.cells[3].text = f"${ctx['total_actual'] + ctx['indirect_amount']:,.0f}"
322
+ for cell in row.cells:
323
+ for run in cell.paragraphs[0].runs:
324
+ run.bold = True
325
+ run.font.size = Pt(9)
326
+
327
+ if ctx['milestones']:
328
+ doc.add_heading('Milestones', level=1)
329
+ for m in ctx['milestones']:
330
+ check = "[x]" if m.get("completed") else "[ ]"
331
+ date_str = f" ({m['date']})" if m.get('date') else ''
332
+ doc.add_paragraph(f"{check} {m.get('name', '')}{date_str}")
333
+
334
+ if ctx['reports']:
335
+ doc.add_heading('Reports', level=1)
336
+ for r in reversed(ctx['reports']):
337
+ doc.add_heading(r.get('title', 'Report'), level=2)
338
+ if r.get('date'):
339
+ p = doc.add_paragraph(f"Date: {r['date']}")
340
+ p.runs[0].font.color.rgb = RGBColor(0x99, 0x99, 0x99)
341
+ if r.get('content'):
342
+ for line in r['content'].split('\n'):
343
+ if line.strip():
344
+ doc.add_paragraph(line.strip())
345
+
346
+ buf = BytesIO()
347
+ doc.save(buf)
348
+ buf.seek(0)
349
+ return buf
350
+
351
+
352
+ @export_bp.route("/export/pdf/<proposal_id>")
353
+ def export_pdf(proposal_id: str) -> Tuple[Response, int] | Response:
354
+ """Export proposal as PDF."""
355
+ if HTML is None:
356
+ return jsonify({"error": GTK3_MISSING_MSG}), 500
357
+
358
+ proposal = Proposal.load(proposal_id)
359
+ if not proposal:
360
+ logger.warning(f"Proposal not found for PDF export: {proposal_id}")
361
+ return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
362
+
363
+ ctx = build_export_context(proposal)
364
+ html_content = render_template("export_proposal.html", **ctx)
365
+
366
+ ensure_export_dir()
367
+ pdf_path = os.path.join(EXPORT_DIR, f"{proposal_id}.pdf")
368
+ HTML(string=html_content, base_url=request.host_url).write_pdf(pdf_path)
369
+
370
+ return send_file(
371
+ pdf_path,
372
+ mimetype="application/pdf",
373
+ as_attachment=True,
374
+ download_name=f"{proposal.title or 'proposal'}.pdf",
375
+ )
376
+
377
+
378
+ @export_bp.route("/export/html/<proposal_id>")
379
+ def export_html(proposal_id: str) -> Tuple[Response, int] | Response:
380
+ """Export proposal as HTML file."""
381
+ proposal = Proposal.load(proposal_id)
382
+ if not proposal:
383
+ logger.warning(f"Proposal not found for HTML export: {proposal_id}")
384
+ return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
385
+
386
+ ctx = build_export_context(proposal)
387
+ html_content = render_template("export_proposal.html", **ctx)
388
+
389
+ return Response(
390
+ html_content,
391
+ mimetype="text/html",
392
+ headers={"Content-Disposition": "inline"},
393
+ )
394
+
395
+
396
+ @export_bp.route("/export/docx/<proposal_id>")
397
+ def export_docx(proposal_id: str) -> Tuple[Response, int] | Response:
398
+ """Export proposal as DOCX file."""
399
+ proposal = Proposal.load(proposal_id)
400
+ if not proposal:
401
+ logger.warning(f"Proposal not found for DOCX export: {proposal_id}")
402
+ return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
403
+
404
+ try:
405
+ buf = _build_proposal_docx(proposal)
406
+ except ImportError:
407
+ return jsonify({"error": "python-docx not installed"}), 500
408
+ except Exception as e:
409
+ logger.error(f"DOCX export failed: {e}")
410
+ return jsonify({"error": f"DOCX export failed: {str(e)}"}), 500
411
+
412
+ return send_file(
413
+ buf,
414
+ mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
415
+ as_attachment=True,
416
+ download_name=f"{proposal.title or 'proposal'}.docx",
417
+ )
418
+
419
+
420
+ @export_bp.route("/export/tracker/pdf/<proposal_id>")
421
+ def export_tracker_pdf(proposal_id: str) -> Tuple[Response, int] | Response:
422
+ """Export tracker as PDF."""
423
+ if HTML is None:
424
+ return jsonify({"error": GTK3_MISSING_MSG}), 500
425
+
426
+ proposal = Proposal.load(proposal_id)
427
+ if not proposal:
428
+ return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
429
+
430
+ ctx = build_tracker_export_context(proposal)
431
+ html_content = render_template("export_tracker.html", **ctx)
432
+
433
+ ensure_export_dir()
434
+ pdf_path = os.path.join(EXPORT_DIR, f"tracker_{proposal_id}.pdf")
435
+ HTML(string=html_content, base_url=request.host_url).write_pdf(pdf_path)
436
+
437
+ return send_file(
438
+ pdf_path,
439
+ mimetype="application/pdf",
440
+ as_attachment=True,
441
+ download_name=f"{proposal.title or 'project'}_tracker.pdf",
442
+ )
443
+
444
+
445
+ @export_bp.route("/export/tracker/html/<proposal_id>")
446
+ def export_tracker_html(proposal_id: str) -> Tuple[Response, int] | Response:
447
+ """Export tracker as HTML."""
448
+ proposal = Proposal.load(proposal_id)
449
+ if not proposal:
450
+ return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
451
+
452
+ ctx = build_tracker_export_context(proposal)
453
+ html_content = render_template("export_tracker.html", **ctx)
454
+
455
+ return Response(
456
+ html_content,
457
+ mimetype="text/html",
458
+ headers={"Content-Disposition": "inline"},
459
+ )
460
+
461
+
462
+ @export_bp.route("/export/tracker/docx/<proposal_id>")
463
+ def export_tracker_docx(proposal_id: str) -> Tuple[Response, int] | Response:
464
+ """Export tracker as DOCX."""
465
+ proposal = Proposal.load(proposal_id)
466
+ if not proposal:
467
+ return jsonify(ERROR_MESSAGES['PROPOSAL_NOT_FOUND']), 404
468
+
469
+ ctx = build_tracker_export_context(proposal)
470
+
471
+ try:
472
+ buf = _build_tracker_docx(proposal, ctx)
473
+ except ImportError:
474
+ return jsonify({"error": "python-docx not installed"}), 500
475
+ except Exception as e:
476
+ logger.error(f"Tracker DOCX export failed: {e}")
477
+ return jsonify({"error": f"DOCX export failed: {str(e)}"}), 500
478
+
479
+ return send_file(
480
+ buf,
481
+ mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
482
+ as_attachment=True,
483
+ download_name=f"{proposal.title or 'project'}_tracker.docx",
484
+ )