1 from collections
import defaultdict
2 from pathlib
import Path
3 from typing
import Dict
, List
, Any
, Optional
4 from io
import StringIO
6 from budget_sync
.budget_graph
import BudgetGraph
, Node
, Payment
, PayeeState
, PaymentSummary
7 from budget_sync
.config
import Person
, Milestone
8 from budget_sync
.money
import Money
9 from budget_sync
.ordered_set
import OrderedSet
10 from budget_sync
.util
import BugStatus
13 def _markdown_escape_char(char
: str) -> str:
18 if char
in "\\`*_{}[]()#+-.!":
23 def markdown_escape(v
: Any
) -> str:
24 return "".join([_markdown_escape_char(char
) for char
in str(v
)])
27 class DisplayStatus(enum
.Enum
):
29 NotYetStarted
= "Not yet started"
30 InProgress
= "Currently working on"
31 Completed
= "Completed but not yet added to payees list"
34 def from_status(status
: BugStatus
) -> "DisplayStatus":
35 return _DISPLAY_STATUS_MAP
[status
]
38 _DISPLAY_STATUS_MAP
= {
39 BugStatus
.UNCONFIRMED
: DisplayStatus
.Hidden
,
40 BugStatus
.CONFIRMED
: DisplayStatus
.NotYetStarted
,
41 BugStatus
.IN_PROGRESS
: DisplayStatus
.InProgress
,
42 BugStatus
.DEFERRED
: DisplayStatus
.Hidden
,
43 BugStatus
.RESOLVED
: DisplayStatus
.Completed
,
44 BugStatus
.VERIFIED
: DisplayStatus
.Completed
,
45 BugStatus
.PAYMENTPENDING
: DisplayStatus
.Completed
,
50 last_headers
: List
[str]
53 self
.buffer = StringIO()
54 self
.last_headers
= []
56 def write_headers(self
, headers
: List
[str]):
57 if headers
== self
.last_headers
:
59 for i
in range(len(headers
)):
60 if i
>= len(self
.last_headers
):
61 print(headers
[i
], file=self
.buffer)
62 self
.last_headers
.append(headers
[i
])
63 elif headers
[i
] != self
.last_headers
[i
]:
64 del self
.last_headers
[i
:]
65 print(headers
[i
], file=self
.buffer)
66 self
.last_headers
.append(headers
[i
])
67 if len(self
.last_headers
) > len(headers
):
68 raise ValueError("tried to go from deeper header scope stack to "
69 "ancestor scope without starting a new header, "
70 "which is not supported by markdown",
71 self
.last_headers
, headers
)
72 assert headers
== self
.last_headers
74 def write_node_header(self
,
76 node
: Optional
[Node
]):
77 self
.write_headers(headers
)
79 print("* None", file=self
.buffer)
81 summary
= markdown_escape(node
.bug
.summary
)
82 print(f
"* [Bug #{node.bug.id}]({node.bug_url}):\n {summary}",
88 payment
: Optional
[Payment
]):
89 self
.write_node_header(headers
, node
)
90 if payment
is not None:
91 if node
.fixed_budget_excluding_subtasks \
92 != node
.budget_excluding_subtasks
:
93 total
= (f
"€{node.fixed_budget_excluding_subtasks} ("
94 f
"total is fixed from amount appearing in bug report,"
95 f
" which is €{node.budget_excluding_subtasks})")
97 total
= f
"€{node.fixed_budget_excluding_subtasks}"
99 print(f
" * submitted on {payment.submitted}",
102 print(f
" * paid on {payment.paid}",
104 if payment
.amount
!= node
.fixed_budget_excluding_subtasks \
105 or payment
.amount
!= node
.budget_excluding_subtasks
:
106 print(f
" * €{payment.amount} out of total of {total}",
109 print(f
" * €{payment.amount} which is the total amount",
111 closest
= node
.closest_bug_in_mou
113 print(f
" * this task is a MoU Milestone",
115 elif closest
is not None:
116 print(f
" * this task is part of MoU Milestone\n"
117 f
" [Bug #{closest.bug.id}]({closest.bug_url})",
119 elif payment
is not None: # only report this if there's a payment
120 print(f
" * neither this task nor any parent tasks are in "
125 def _markdown_for_person(person
: Person
,
126 payments_dict
: Dict
[Milestone
, List
[Payment
]],
127 assigned_nodes
: List
[Node
],
128 nodes_subset
: Optional
[OrderedSet
[Node
]] = None,
130 def node_included(node
: Node
) -> bool:
131 return nodes_subset
is None or node
in nodes_subset
132 writer
= MarkdownWriter()
133 print(f
"<!-- autogenerated by budget-sync -->", file=writer
.buffer)
134 writer
.write_headers([f
"\n# {person.full_name} ({person.identifier})\n"])
135 print(file=writer
.buffer)
136 status_tracking_header
= "\n# Status Tracking\n"
137 writer
.write_headers([status_tracking_header
])
138 displayed_nodes_dict
: Dict
[DisplayStatus
, List
[Node
]]
139 displayed_nodes_dict
= {i
: [] for i
in DisplayStatus
}
140 for node
in assigned_nodes
:
141 display_status
= DisplayStatus
.from_status(node
.status
)
142 displayed_nodes_dict
[display_status
].append(node
)
144 def write_display_status_chunk(display_status
: DisplayStatus
):
145 display_status_header
= f
"\n## {display_status.value}\n"
146 for node
in displayed_nodes_dict
[display_status
]:
147 if not node_included(node
):
149 if display_status
== DisplayStatus
.Completed
:
150 payment_found
= False
151 for payment
in node
.payments
.values():
152 if payment
.payee
== person
:
157 if len(node
.payments
) == 0 \
158 and node
.budget_excluding_subtasks
== 0 \
159 and node
.budget_including_subtasks
== 0:
162 headers
=[status_tracking_header
, display_status_header
],
163 node
=node
, payment
=None)
165 for display_status
in DisplayStatus
:
166 if display_status
== DisplayStatus
.Hidden \
167 or display_status
== DisplayStatus
.NotYetStarted
:
169 write_display_status_chunk(display_status
)
171 for payee_state
in PayeeState
:
172 # work out headers per status
173 if payee_state
== PayeeState
.NotYetSubmitted
:
174 display_status_header
= "\n## Payment not yet submitted\n"
175 subtotals_header
= ("\nMoU Milestone subtotals for not "
176 "yet submitted payments\n")
177 elif payee_state
== PayeeState
.Submitted
:
178 display_status_header
= ("\n## Submitted to NLNet but "
180 subtotals_header
= ("\nMoU Milestone subtotals for "
181 "submitted but not yet paid payments\n")
183 assert payee_state
== PayeeState
.Paid
184 display_status_header
= "\n## Paid by NLNet\n"
185 subtotals_header
= ("\nMoU Milestone subtotals for paid "
187 # list all the payments grouped by Grant
188 for milestone
, payments_list
in payments_dict
.items():
189 milestone_header
= f
"\n### {milestone.identifier}\n"
190 mou_subtotals
: Dict
[Optional
[Node
], Money
] = defaultdict(Money
)
191 headers
= [status_tracking_header
,
192 display_status_header
,
194 # write out the payments and also compute the subtotals per
196 for payment
in payments_list
:
198 if payment
.state
== payee_state
and node_included(node
):
199 mou_subtotals
[node
.closest_bug_in_mou
] += payment
.amount
200 writer
.write_node(headers
=headers
,
201 node
=payment
.node
, payment
=payment
)
202 headers
.append(subtotals_header
)
203 # now display the mou subtotals. really, this should be before
204 for node
, subtotal
in mou_subtotals
.items():
205 writer
.write_node_header(headers
, node
)
208 elif node
.fixed_budget_including_subtasks \
209 != node
.budget_including_subtasks
:
210 budget
= (" out of total including subtasks of "
211 f
"€{node.fixed_budget_including_subtasks}"
212 " (budget is fixed from amount appearing in "
213 "bug report, which is "
214 f
"€{node.budget_including_subtasks})")
216 budget
= (" out of total including subtasks of "
217 f
"€{node.fixed_budget_including_subtasks}")
218 print(f
" * subtotal €{subtotal}{budget}",
221 # write_display_status_chunk(DisplayStatus.NotYetStarted)
223 return writer
.buffer.getvalue()
226 def write_budget_markdown(budget_graph
: BudgetGraph
,
228 nodes_subset
: Optional
[OrderedSet
[Node
]] = None):
229 output_dir
.mkdir(parents
=True, exist_ok
=True)
230 for person
, payments_dict
in budget_graph
.payments
.items():
231 markdown
= _markdown_for_person(person
,
233 budget_graph
.assigned_nodes
[person
],
235 output_file
= output_dir
.joinpath(person
.output_markdown_file
)
236 output_file
.write_text(markdown
, encoding
="utf-8")
239 def markdown_for_person(budget_graph
: BudgetGraph
, person
: Person
,
240 nodes_subset
: Optional
[OrderedSet
[Node
]] = None,
242 return _markdown_for_person(person
, budget_graph
.payments
[person
],
243 budget_graph
.assigned_nodes
[person
],