slab-cli 0.9.0__tar.gz → 0.11.0__tar.gz

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.
Files changed (34) hide show
  1. {slab_cli-0.9.0 → slab_cli-0.11.0}/PKG-INFO +1 -1
  2. {slab_cli-0.9.0 → slab_cli-0.11.0}/pyproject.toml +1 -1
  3. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/client.py +9 -2
  4. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/commands/breaks.py +8 -3
  5. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/commands/collection.py +13 -0
  6. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/commands/custom_sets.py +44 -9
  7. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/display.py +23 -6
  8. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/prompts.py +24 -2
  9. {slab_cli-0.9.0 → slab_cli-0.11.0}/README.md +0 -0
  10. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/__init__.py +0 -0
  11. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/banner.py +0 -0
  12. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/browse.py +0 -0
  13. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/commands/__init__.py +0 -0
  14. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/commands/catalog.py +0 -0
  15. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/commands/export.py +0 -0
  16. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/commands/lots.py +0 -0
  17. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/commands/pricing.py +0 -0
  18. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/commands/registry.py +0 -0
  19. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/commands/setup.py +0 -0
  20. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/commands/update.py +0 -0
  21. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/config.py +0 -0
  22. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/context.py +0 -0
  23. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/flags.py +0 -0
  24. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/help.py +0 -0
  25. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/main.py +0 -0
  26. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/paging.py +0 -0
  27. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/picker.py +0 -0
  28. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/platforms/__init__.py +0 -0
  29. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/platforms/base.py +0 -0
  30. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/platforms/posix.py +0 -0
  31. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/platforms/windows.py +0 -0
  32. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/sources.py +0 -0
  33. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/theme.py +0 -0
  34. {slab_cli-0.9.0 → slab_cli-0.11.0}/src/slab_cli/updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slab-cli
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
5
5
  Author: dev_jeb
6
6
  Requires-Dist: slab-schemas>=0.1.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.9.0"
3
+ version = "0.11.0"
4
4
  description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
5
5
  readme = "README.md"
6
6
  authors = [{name = "dev_jeb"}]
@@ -336,7 +336,14 @@ class SlabClient:
336
336
  resp = self._request("POST", "/custom-sets/search", json=body)
337
337
  return CustomSetSearchResult.model_validate(resp.json())
338
338
 
339
- def get_custom_set(self, set_uuid: str, collector_uuid: str | None = None) -> CustomSetDetail:
340
- params = {"collector_uuid": collector_uuid} if collector_uuid else {}
339
+ def get_custom_set(
340
+ self, set_uuid: str, collector_uuid: str | None = None,
341
+ limit: int | None = None, offset: int | None = None,
342
+ ) -> CustomSetDetail:
343
+ params: dict = {"collector_uuid": collector_uuid} if collector_uuid else {}
344
+ if limit is not None:
345
+ params["limit"] = limit
346
+ if offset is not None:
347
+ params["offset"] = offset
341
348
  resp = self._request("GET", f"/custom-sets/{set_uuid}", params=params)
342
349
  return CustomSetDetail.model_validate(resp.json())
@@ -6,7 +6,6 @@ from __future__ import annotations
6
6
 
7
7
  from slab_schemas.cards import SetSearchQuery
8
8
  from slab_schemas.collection import BreakCreate, BreakOut, BreakSearchQuery, CollectionSearchQuery
9
- from slab_schemas.enums import BreakType
10
9
 
11
10
  from ..client import SlabClient
12
11
  from ..context import ctx
@@ -59,7 +58,13 @@ def create_break_interactive(client: SlabClient, collector: str) -> BreakOut | N
59
58
  if set_choice is None:
60
59
  return None
61
60
 
62
- break_type = select_break_type()
61
+ # Offer the formats this set actually comes in (its sealed SKUs); a set with no SKUs — or a
62
+ # failed lookup — falls back to the generic picker. Never let the extra lookup kill the flow.
63
+ try:
64
+ formats = [p.format.value for p in client.get_set_sealed(set_choice.uuid)]
65
+ except Exception:
66
+ formats = []
67
+ break_type = select_break_type(formats)
63
68
  break_date = ask_date("When did you open it?")
64
69
  total_cost = ask_decimal("Total cost:")
65
70
  # Offer the collector's previously-used sources for arrow-key reuse (falls back to a plain
@@ -70,7 +75,7 @@ def create_break_interactive(client: SlabClient, collector: str) -> BreakOut | N
70
75
  collector,
71
76
  BreakCreate(
72
77
  set_uuid=set_choice.uuid,
73
- break_type=BreakType(break_type),
78
+ break_type=break_type,
74
79
  break_date=break_date,
75
80
  total_cost=total_cost,
76
81
  source=source,
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import re
6
6
  from dataclasses import dataclass, field
7
+ from datetime import date
7
8
  from decimal import Decimal
8
9
 
9
10
  from slab_schemas.cards import CardOut, CardSearchQuery
@@ -68,6 +69,7 @@ class AddSession:
68
69
  lot_uuid: str | None = None # the purchase this batch belongs to; its cost splits across cards
69
70
  release_set_uuid: str | None = None # the break's product — scopes every card search to that box
70
71
  acquired_source: str | None = None # where these came from (purchases/trades/gifts), set once
72
+ acquired_date: date | None = None # when you got them; None for break/lot cards (theirs wins)
71
73
  cards_added: int = 0
72
74
 
73
75
 
@@ -119,6 +121,14 @@ def _ensure_acquisition_context(session: AddSession) -> None:
119
121
  # Trade / gift / other → ask where these came from, once for the batch.
120
122
  session.acquired_source = pick_source(session.client, session.collector)
121
123
 
124
+ # When you got them — asked once, and ONLY for standalone cards: a break/lot card takes its
125
+ # date from the container (which already asked), and storing a second copy of it would just be
126
+ # something else to keep in sync. Enter defaults to today, so the common "adding what I bought
127
+ # today" case is one keystroke — but an old pickup can be dated correctly, which matters
128
+ # because the portfolio chart only counts a card from the day you got it.
129
+ if session.break_uuid is None and session.lot_uuid is None:
130
+ session.acquired_date = ask_date("When did you get it?")
131
+
122
132
 
123
133
  def _per_card_cost(session: AddSession) -> Decimal | None:
124
134
  """The cost that varies per card. Pack pulls derive cost from the break, and a purchase attached
@@ -164,6 +174,8 @@ def _show_review(card: CardOut, session: AddSession, acq_cost: Decimal | None,
164
174
  console.print(f" Purchase: {session.lot_uuid[:8]} [dim](cost split across the lot)[/dim]")
165
175
  if acq_cost is not None:
166
176
  console.print(f" Cost: ${acq_cost:.2f}")
177
+ if session.acquired_date:
178
+ console.print(f" Date: {session.acquired_date}")
167
179
  if session.acquired_source:
168
180
  console.print(f" Source: {session.acquired_source}")
169
181
  if self_grade:
@@ -197,6 +209,7 @@ def _submit_and_report(session: AddSession, card: CardOut, acq_cost: Decimal | N
197
209
  lot_uuid=session.lot_uuid,
198
210
  acquisition_type=session.acq_type,
199
211
  acquisition_cost=acq_cost,
212
+ acquired_date=session.acquired_date,
200
213
  acquired_source=session.acquired_source,
201
214
  self_grade=self_grade,
202
215
  grading_company=grading_company,
@@ -5,13 +5,16 @@ from __future__ import annotations
5
5
  from InquirerPy import inquirer
6
6
  from InquirerPy.base.control import Choice
7
7
 
8
+ from types import SimpleNamespace
9
+
8
10
  from slab_schemas.custom_sets import (
9
11
  CustomSetCardAdd,
10
12
  CustomSetCreate,
13
+ CustomSetDetail,
11
14
  CustomSetOut,
12
15
  CustomSetSearchQuery,
13
16
  )
14
- from slab_schemas.enums import CustomSetType, MatchMode, Visibility
17
+ from slab_schemas.enums import CustomSetType, DynamicMatch, MatchMode, Visibility
15
18
 
16
19
  from ..client import SlabClient
17
20
  from ..context import ctx
@@ -21,6 +24,7 @@ from ..display import (
21
24
  format_card_summary,
22
25
  print_custom_set_detail,
23
26
  )
27
+ from ..paging import fetch_all_pages
24
28
  from ..picker import search_and_pick_subject
25
29
  from ..prompts import (
26
30
  ask_int_optional,
@@ -28,6 +32,7 @@ from ..prompts import (
28
32
  ask_text_optional,
29
33
  confirm,
30
34
  select_custom_set_type,
35
+ select_dynamic_match,
31
36
  select_match_mode,
32
37
  select_printing,
33
38
  select_visibility,
@@ -38,6 +43,25 @@ from ..prompts import (
38
43
  # Helpers
39
44
  # ---------------------------------------------------------------------------
40
45
 
46
+ def _fetch_full_detail(
47
+ client: SlabClient, set_uuid: str, collector: str | None
48
+ ) -> CustomSetDetail:
49
+ """The set with its WHOLE card list — the detail endpoint pages `cards` (completion stats
50
+ already cover the full set), so flows that show or iterate every entry stitch the pages back
51
+ together here. Capped at 5000 entries as a runaway bound (a `year`-only dynamic filter can
52
+ match a whole catalog); past the cap, `card_count` > len(cards) and the display says so."""
53
+ first: list = []
54
+
55
+ def fetch(limit: int, offset: int):
56
+ d = client.get_custom_set(set_uuid, collector, limit=limit, offset=offset)
57
+ if not first:
58
+ first.append(d)
59
+ return SimpleNamespace(items=d.cards, total=d.card_count)
60
+
61
+ cards, _ = fetch_all_pages(fetch, page=500, cap=5000)
62
+ return first[0].model_copy(update={"cards": cards, "offset": 0})
63
+
64
+
41
65
  def _select_custom_set(client: SlabClient, collector: str, message: str = "Select a set:") -> CustomSetOut | None:
42
66
  """Browse the collector's custom sets and pick one."""
43
67
  sets = client.collector_custom_sets(collector)
@@ -98,6 +122,7 @@ def cmd_chase_create(args: list[str]) -> None:
98
122
  visibility = select_visibility()
99
123
 
100
124
  filter_json = None
125
+ dynamic_match = "any_printing"
101
126
  if set_type == "dynamic":
102
127
  console.print("\n[dim]A dynamic set is defined by a search filter. Enter the filter criteria.[/dim]")
103
128
  console.print("[dim]Cards matching this filter will automatically be in the set.[/dim]\n")
@@ -106,6 +131,15 @@ def cmd_chase_create(args: list[str]) -> None:
106
131
  if filter_json is None:
107
132
  console.print("[dim]Cancelled.[/dim]")
108
133
  return
134
+ # One set-wide granularity. Default follows the filter's intent: a filter that names a
135
+ # finish/parallel dimension reads as a per-printing chase; anything else (team, player,
136
+ # season) reads as "one of each card — base or any parallel counts".
137
+ chasing_printings = any(
138
+ k in filter_json for k in ("finish", "parallel_only", "is_numbered", "is_1of1")
139
+ )
140
+ dynamic_match = select_dynamic_match(
141
+ default="exact" if chasing_printings else "any_printing"
142
+ )
109
143
 
110
144
  cs = ctx.client.create_custom_set(collector, CustomSetCreate(
111
145
  name=name,
@@ -113,6 +147,7 @@ def cmd_chase_create(args: list[str]) -> None:
113
147
  visibility=Visibility(visibility),
114
148
  set_type=CustomSetType(set_type),
115
149
  filter_json=filter_json,
150
+ dynamic_match=DynamicMatch(dynamic_match),
116
151
  ))
117
152
 
118
153
  console.print(f"\n[green]Created![/green] {cs.name} [dim]({cs.uuid[:8]})[/dim]")
@@ -277,12 +312,12 @@ def cmd_chase_view(args: list[str]) -> None:
277
312
  client = ctx.client
278
313
 
279
314
  if args:
280
- detail = client.get_custom_set(args[0], collector)
315
+ detail = _fetch_full_detail(client, args[0], collector)
281
316
  else:
282
317
  cs = _select_custom_set(client, collector, message="Which set to view?")
283
318
  if cs is None:
284
319
  return
285
- detail = client.get_custom_set(cs.uuid, collector)
320
+ detail = _fetch_full_detail(client, cs.uuid, collector)
286
321
 
287
322
  print_custom_set_detail(detail)
288
323
 
@@ -311,8 +346,8 @@ def cmd_chase_edit(args: list[str]) -> None:
311
346
  if cs is None:
312
347
  return
313
348
 
314
- # Check ownership
315
- detail = client.get_custom_set(cs.uuid, collector)
349
+ # Check ownership — metadata only, skip the card list
350
+ detail = client.get_custom_set(cs.uuid, collector, limit=1)
316
351
  if detail.creator_uuid != collector:
317
352
  console.print("[yellow]You can only edit sets you created.[/yellow]")
318
353
  return
@@ -350,7 +385,7 @@ def cmd_chase_edit(args: list[str]) -> None:
350
385
 
351
386
  def _chase_detail(s) -> None:
352
387
  """The drill-in for a browsed chase set: its full card list + completion, owner-aware."""
353
- print_custom_set_detail(ctx.client.get_custom_set(s.uuid, ctx.collector))
388
+ print_custom_set_detail(_fetch_full_detail(ctx.client, s.uuid, ctx.collector))
354
389
 
355
390
 
356
391
  def cmd_chase_list(args: list[str]) -> None:
@@ -436,7 +471,7 @@ def cmd_chase_remove(args: list[str]) -> None:
436
471
  console.print("[yellow]Can only remove cards from curated sets.[/yellow]")
437
472
  return
438
473
 
439
- detail = client.get_custom_set(cs.uuid, collector)
474
+ detail = _fetch_full_detail(client, cs.uuid, collector)
440
475
  if not detail.cards:
441
476
  console.print("[dim]This set has no cards.[/dim]")
442
477
  return
@@ -475,8 +510,8 @@ def cmd_chase_delete(args: list[str]) -> None:
475
510
  if cs is None:
476
511
  return
477
512
 
478
- # Check ownership
479
- detail = client.get_custom_set(cs.uuid, collector)
513
+ # Check ownership — metadata only, skip the card list
514
+ detail = client.get_custom_set(cs.uuid, collector, limit=1)
480
515
  if detail.creator_uuid != collector:
481
516
  console.print("[yellow]You can only delete sets you created.[/yellow]")
482
517
  return
@@ -261,7 +261,7 @@ BREAK_COLUMNS = [
261
261
  def break_cells(b) -> list[str]:
262
262
  """One break as its BREAK_COLUMNS cells."""
263
263
  cpc = money(b.cost_per_card) if b.cost_per_card is not None else "—"
264
- return [b.set_name or "", b.break_type, money(b.total_cost), str(b.copy_count), cpc,
264
+ return [b.set_name or "", _format_label(b.break_type), money(b.total_cost), str(b.copy_count), cpc,
265
265
  str(b.break_date or "—"), b.source or "—", b.uuid[:8]]
266
266
 
267
267
 
@@ -314,7 +314,7 @@ def render_break_pulls(brk, result: CollectionResult) -> None:
314
314
  console.print()
315
315
  t = Text()
316
316
  t.append(brk.set_name or "Break", style="slab.value")
317
- t.append(f" — {brk.break_type} · {brk.break_date or '—'}"
317
+ t.append(f" — {brk.break_type.replace('_', ' ')} · {brk.break_date or '—'}"
318
318
  + (f" · {brk.source}" if brk.source else ""), style="slab.dim")
319
319
  console.print(t)
320
320
 
@@ -530,10 +530,15 @@ def render_dashboard(d: DashboardStats) -> None:
530
530
  if d.priced_coverage is not None:
531
531
  rows.append(("Coverage", f"{pct_coverage(float(d.priced_coverage) * 100)} [slab.dim]priced[/]"))
532
532
  # Portfolio value over time — the CLI stand-in for the portal's chart, from the same payload.
533
+ # Each point values only the copies owned that day, so the net includes cards you ADDED in
534
+ # the window, not just price movement — said plainly so it isn't read as a 90d gain.
533
535
  if len(d.portfolio_series) >= 2:
534
536
  vals = [p.portfolio_value for p in d.portfolio_series]
535
537
  net = float(vals[-1]) - float(vals[0])
536
- rows.append(("Trend 90d", f"{sparkline(vals)} {signed_money(net)}"))
538
+ rows.append((
539
+ "Trend 90d",
540
+ f"{sparkline(vals)} {signed_money(net)} [slab.dim]incl. cards added[/]",
541
+ ))
537
542
  value = section("Value", kv_grid(rows), foil=True)
538
543
 
539
544
  comp = _composition_panel(
@@ -555,7 +560,8 @@ def render_dashboard(d: DashboardStats) -> None:
555
560
  ("$/card", {"justify": "right", "style": "slab.dim"}),
556
561
  ])
557
562
  for b in d.break_stats:
558
- table.add_row(str(b.break_date or "—"), b.set_name or "—", b.break_type or "—",
563
+ table.add_row(str(b.break_date or "—"), b.set_name or "—",
564
+ _format_label(b.break_type) if b.break_type else "—",
559
565
  money(b.total_cost), str(b.cards), money(b.cost_per_card))
560
566
  breaks = titled("Breaks", table)
561
567
 
@@ -804,7 +810,11 @@ def print_custom_set_detail(detail: CustomSetDetail) -> None:
804
810
  vis = "Public" if detail.visibility == "public" else "Private"
805
811
  sub = "subscribed" if detail.is_subscribed else "not subscribed"
806
812
 
807
- head = [f"[slab.dim]{detail.set_type} · {vis} · {sub}[/]"]
813
+ kind = detail.set_type
814
+ if detail.dynamic_match:
815
+ grain = "any printing counts" if detail.dynamic_match == "any_printing" else "exact printings"
816
+ kind = f"{detail.set_type} ({grain})"
817
+ head = [f"[slab.dim]{kind} · {vis} · {sub}[/]"]
808
818
  if detail.description:
809
819
  head.append(detail.description)
810
820
  head.append(f"[slab.label]Creator[/] {detail.creator_name or '—'} "
@@ -847,6 +857,13 @@ def print_custom_set_detail(detail: CustomSetDetail) -> None:
847
857
  table.add_row(str(entry.position), label, match, owned)
848
858
 
849
859
  console.print(table)
860
+ if detail.card_count > len(detail.cards):
861
+ first = detail.offset + 1
862
+ last = detail.offset + len(detail.cards)
863
+ console.print(
864
+ f" [slab.dim]Showing entries {first}–{last} of {detail.card_count} "
865
+ f"(completion covers the full set).[/]"
866
+ )
850
867
  console.print()
851
868
 
852
869
 
@@ -1073,7 +1090,7 @@ def _render_most_valuable_break(mvb: dict | None) -> None:
1073
1090
  date_str = f" · {mvb['break_date']}" if mvb.get("break_date") else ""
1074
1091
  title = Text()
1075
1092
  title.append(mvb["set_name"], style="slab.value")
1076
- title.append(f" {mvb['break_type']}{date_str}", style="slab.dim")
1093
+ title.append(f" {mvb['break_type'].replace('_', ' ')}{date_str}", style="slab.dim")
1077
1094
 
1078
1095
  g = mvb["gain"]
1079
1096
  verdict = "paid off" if g >= 0 else "underwater"
@@ -62,7 +62,7 @@ def _break_label(b: BreakOut) -> str:
62
62
  when = b.break_date.isoformat() if b.break_date else "—"
63
63
  src = f" · {b.source}" if b.source else ""
64
64
  cpc = f" @ ${b.cost_per_card:.2f}/card" if b.cost_per_card else ""
65
- return (f"{when} · {b.set_name} · {b.break_type} · ${b.total_cost:.2f}{src} · "
65
+ return (f"{when} · {b.set_name} · {b.break_type.replace('_', ' ')} · ${b.total_cost:.2f}{src} · "
66
66
  f"{b.copy_count} cards{cpc} [{b.uuid[:8]}]")
67
67
 
68
68
 
@@ -388,7 +388,16 @@ def select_acquisition_type() -> str:
388
388
  return inquirer.select(message="How did you acquire this card?", choices=choices).execute()
389
389
 
390
390
 
391
- def select_break_type() -> str:
391
+ def select_break_type(formats: list[str] | None = None) -> str:
392
+ """What was opened. When the set's sealed SKUs are known, offer those formats first (the
393
+ things actually purchasable for THIS set) with an Other… escape to the generic list; a set
394
+ with no SKUs goes straight to the generic pack/box/case/other."""
395
+ if formats:
396
+ choices = [Choice(value=f, name=f.replace("_", " ").title()) for f in formats]
397
+ choices.append(Choice(value=None, name="Other…"))
398
+ picked = inquirer.select(message="What did you open?", choices=choices).execute()
399
+ if picked is not None:
400
+ return picked
392
401
  choices = [Choice(value=t.value, name=_enum_label(t, _BREAK_TYPE_LABELS)) for t in BreakType]
393
402
  return inquirer.select(message="What did you open?", choices=choices).execute()
394
403
 
@@ -443,6 +452,19 @@ def select_custom_set_type() -> str:
443
452
  return inquirer.select(message="Set type:", choices=choices).execute()
444
453
 
445
454
 
455
+ def select_dynamic_match(default: str = "any_printing") -> str:
456
+ """The set-wide granularity of a dynamic set — what counts as completing a card."""
457
+ choices = [
458
+ Choice(value="any_printing",
459
+ name="Any printing — one entry per card; base or any parallel completes it"),
460
+ Choice(value="exact",
461
+ name="Exact — every matching parallel is its own entry to chase"),
462
+ ]
463
+ return inquirer.select(
464
+ message="What counts as having a card?", choices=choices, default=default,
465
+ ).execute()
466
+
467
+
446
468
  def select_cost_category() -> str:
447
469
  choices = [
448
470
  Choice(value="grading", name="Grading fee"),
File without changes
File without changes
File without changes