monopyly 1.5.0__py3-none-any.whl → 1.5.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.
monopyly/CHANGELOG.md CHANGED
@@ -199,4 +199,15 @@
199
199
  - Create a tool for reconciling credit card transactions with information collected from external resources (e.g., CSVs downloaded from a user's online credit card account)
200
200
 
201
201
 
202
+ ### 1.5.1
203
+
204
+ - Bump dependencies (including patching security vulnerability in NLTK)
205
+ - Style credit transaction submissions as receipts
206
+ - Style flash messages according to content
207
+ - Return to statement details page after deleting a transaction (rather than returning to the general transactions page)
208
+ - Allow users to change their password
209
+ - Warn users before form submission if the configuration currently disallows registration
210
+ - Increase the flexibility of the credit activity parser
211
+
212
+
202
213
  <a name="bottom" id="bottom"></a>
monopyly/_version.py CHANGED
@@ -1,4 +1,16 @@
1
1
  # file generated by setuptools_scm
2
2
  # don't change, don't track in version control
3
- __version__ = version = '1.5.0'
4
- __version_tuple__ = version_tuple = (1, 5, 0)
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '1.5.1'
16
+ __version_tuple__ = version_tuple = (1, 5, 1)
monopyly/auth/actions.py CHANGED
@@ -1,5 +1,10 @@
1
1
  """Module describing logical authorization actions (to be used in routes)."""
2
2
 
3
+ from flask import current_app
4
+ from sqlalchemy import select
5
+
6
+ from ..database.models import User
7
+
3
8
 
4
9
  def get_username_and_password(form):
5
10
  """
@@ -11,3 +16,10 @@ def get_username_and_password(form):
11
16
  username = form["username"].lower()
12
17
  password = form["password"]
13
18
  return username, password
19
+
20
+
21
+ def identify_user(username):
22
+ """Identify the user in the database based on the username."""
23
+ user_query = select(User).where(User.username == username)
24
+ user = current_app.db.session.scalar(user_query)
25
+ return user
monopyly/auth/routes.py CHANGED
@@ -5,6 +5,7 @@ Routes for site authentication.
5
5
  from flask import (
6
6
  current_app,
7
7
  flash,
8
+ g,
8
9
  redirect,
9
10
  render_template,
10
11
  request,
@@ -16,7 +17,7 @@ from sqlalchemy import select
16
17
  from werkzeug.security import check_password_hash, generate_password_hash
17
18
 
18
19
  from ..database.models import User
19
- from .actions import get_username_and_password
20
+ from .actions import get_username_and_password, identify_user
20
21
  from .blueprint import bp
21
22
 
22
23
 
@@ -33,24 +34,17 @@ def register():
33
34
  error = "Username is required."
34
35
  elif not password:
35
36
  error = "Password is required."
37
+ elif user := identify_user(username):
38
+ error = f"User {username} is already registered."
36
39
  else:
37
- # Get user information from the database
38
- user_query = select(User).where(User.username == username)
39
- user = current_app.db.session.scalar(user_query)
40
- if user:
41
- error = f"User {username} is already registered."
42
- else:
43
- error = None
44
- # Add the username and hashed password to the database
45
- if not error:
40
+ # Create a new user
46
41
  new_user = User(
47
42
  username=username,
48
43
  password=generate_password_hash(password),
49
44
  )
50
45
  current_app.db.session.add(new_user)
51
46
  return redirect(url_for("auth.login"))
52
- else:
53
- flash(error)
47
+ flash(error)
54
48
  # Display the registration page
55
49
  return render_template("auth/register.html")
56
50
 
@@ -60,23 +54,17 @@ def login():
60
54
  if request.method == "POST":
61
55
  # Get username and passwords from the form
62
56
  username, password = get_username_and_password(request.form)
63
- # Get user information from the database
64
- user_query = select(User).where(User.username == username)
65
- user = current_app.db.session.scalar(user_query)
66
57
  # Check for errors in the accessed information
67
- if user is None:
58
+ if (user := identify_user(username)) is None:
68
59
  error = "That user is not yet registered."
69
60
  elif not check_password_hash(user.password, password):
70
61
  error = "Incorrect username and password combination."
71
62
  else:
72
- error = None
73
- # Set the user ID securely for a new session
74
- if not error:
63
+ # Set the user ID securely for a new session
75
64
  session.clear()
76
65
  session["user_id"] = user.id
77
66
  return redirect(url_for("core.index"))
78
- else:
79
- flash(error)
67
+ flash(error)
80
68
  # Display the login page
81
69
  return render_template("auth/login.html")
82
70
 
@@ -86,3 +74,24 @@ def logout():
86
74
  # End the session and clear the user ID
87
75
  session.clear()
88
76
  return redirect(url_for("core.index"))
77
+
78
+
79
+ @bp.route("/change_password", methods=("GET", "POST"))
80
+ @db_transaction
81
+ def change_password():
82
+ if request.method == "POST":
83
+ current_password = request.form["current-password"]
84
+ new_password = request.form["new-password"]
85
+ if check_password_hash(g.user.password, current_password):
86
+ # Merge the user item (dissociated from the current session) for updating
87
+ g.user = current_app.db.session.merge(g.user)
88
+ g.user.password = generate_password_hash(new_password)
89
+ flash("Password updated successfully.", category="success")
90
+ return redirect(url_for("core.load_profile"))
91
+ else:
92
+ flash(
93
+ "The provided value for the current password does not match the "
94
+ "value of the current password set on this account.",
95
+ category="error",
96
+ )
97
+ return render_template("auth/change_password.html")
monopyly/auth/tools.py CHANGED
@@ -14,8 +14,7 @@ from .blueprint import bp
14
14
  @bp.before_app_request
15
15
  def load_logged_in_user():
16
16
  # Match the user's information with the session
17
- user_id = session.get("user_id")
18
- if user_id is None:
17
+ if (user_id := session.get("user_id")) is None:
19
18
  g.user = None
20
19
  else:
21
20
  query = select(User).where(User.id == user_id)
@@ -145,7 +145,6 @@ def execute_on_form_validation(func):
145
145
  else:
146
146
  # Show an error to the user and print the errors for the admin
147
147
  flash(form_err_msg)
148
- print(form.errors)
149
- raise ValidationError("The form did not validate properly.")
148
+ raise ValidationError(f"The form did not validate properly: {form.errors}")
150
149
 
151
150
  return wrapper
monopyly/core/routes.py CHANGED
@@ -5,7 +5,6 @@ Routes for core functionality.
5
5
  from pathlib import Path
6
6
 
7
7
  from flask import g, render_template, render_template_string, session
8
- from werkzeug.exceptions import abort
9
8
 
10
9
  from ..auth.tools import login_required
11
10
  from ..banking.accounts import BankAccountHandler
@@ -85,8 +84,3 @@ def load_profile():
85
84
  banks = BankHandler.get_banks()
86
85
  # Return banks as a list to allow multiple reuse
87
86
  return render_template("core/profile.html", banks=list(banks))
88
-
89
-
90
- @bp.route("/change_password")
91
- def change_password():
92
- abort(418)
monopyly/credit/forms.py CHANGED
@@ -301,7 +301,7 @@ class CreditTransactionForm(TransactionForm):
301
301
 
302
302
  def _extract_merchant_suggestion(self, data):
303
303
  # Use the merchant transaction data as a suggestion source
304
- if merchant := self._extract_suggestion(data, "merchant"):
304
+ if merchant := data.get("merchant"):
305
305
  merchant_tokens = ActivityMatchmaker.tokenize(merchant)
306
306
  # Suggest a known merchant with the closest distance to the activity merchant
307
307
  score_records = []
monopyly/credit/routes.py CHANGED
@@ -201,6 +201,8 @@ def load_statement_details(statement_id):
201
201
  categories = categorize(transactions)
202
202
  # Get bank accounts for potential payments
203
203
  bank_accounts = BankAccountHandler.get_accounts()
204
+ # Save a pointer to this statement to allow easy returns
205
+ session["statement_focus"] = statement_id
204
206
  return render_template(
205
207
  "credit/statement_page.html",
206
208
  statement=statement,
@@ -210,6 +212,18 @@ def load_statement_details(statement_id):
210
212
  )
211
213
 
212
214
 
215
+ @bp.before_app_request
216
+ def clear_statement_focus():
217
+ exempt_endpoints = (
218
+ "credit.expand_transaction",
219
+ "credit.delete_transaction",
220
+ "static",
221
+ None,
222
+ )
223
+ if request.endpoint not in exempt_endpoints:
224
+ session.pop("statement_focus", None)
225
+
226
+
213
227
  @bp.route("/_update_statement_due_date/<int:statement_id>", methods=("POST",))
214
228
  @login_required
215
229
  @db_transaction
@@ -307,7 +321,7 @@ def clear_reconciliation_info():
307
321
  "credit.update_transaction",
308
322
  "credit.infer_statement",
309
323
  "credit.suggest_transaction_autocomplete",
310
- "credit.delete_transacton",
324
+ "credit.delete_transaction",
311
325
  "static",
312
326
  None,
313
327
  )
@@ -483,6 +497,10 @@ def add_subtransaction_fields():
483
497
  @db_transaction
484
498
  def delete_transaction(transaction_id):
485
499
  CreditTransactionHandler.delete_entry(transaction_id)
500
+ if statement_id := session.pop("statement_focus", None):
501
+ return redirect(
502
+ url_for("credit.load_statement_details", statement_id=statement_id)
503
+ )
486
504
  return redirect(url_for("credit.load_transactions"))
487
505
 
488
506
 
@@ -4,12 +4,14 @@ import csv
4
4
  from abc import ABC, abstractmethod
5
5
  from pathlib import Path
6
6
 
7
- from flask import current_app
7
+ from flask import abort, current_app
8
8
  from werkzeug.utils import secure_filename
9
9
 
10
10
  from ....common.utils import parse_date
11
11
  from .data import ActivityLoadingError, TransactionActivities, TransactionActivityLoader
12
12
 
13
+ SUPPORTED_BANKS = ("Bank of America", "Chase", "Discover")
14
+
13
15
 
14
16
  def parse_transaction_activity_file(transaction_file):
15
17
  """
@@ -91,7 +93,7 @@ class _TransactionDateColumnIdentifier(_ColumnIdentifier):
91
93
  elif "date" in standardized_title.split():
92
94
  if "trans." in standardized_title:
93
95
  return True
94
- elif standardized_title == "date":
96
+ elif standardized_title == "date" or standardized_title == "posted date":
95
97
  return None
96
98
  return False
97
99
 
@@ -104,8 +106,8 @@ class _TransactionTotalColumnIdentifier(_ColumnIdentifier):
104
106
 
105
107
  class _TransactionDescriptionColumnIdentifier(_ColumnIdentifier):
106
108
  def check(self, standardized_title):
107
- """Check if the title indicates this column contains a description."""
108
- return standardized_title in ("description", "desc.")
109
+ """Check if the title indicates this column contains a description (payee)."""
110
+ return standardized_title in ("description", "desc.", "payee")
109
111
 
110
112
 
111
113
  class _TransactionCategoryColumnIdentifier(_ColumnIdentifier):
@@ -184,9 +186,15 @@ class _TransactionActivityParser:
184
186
  }
185
187
  for column_type in self.column_types:
186
188
  if raw_column_indices[column_type] is None:
187
- raise RuntimeError(
188
- f"The '{column_type}' column could not be identified in the data."
189
+ current_app.logger.debug(
190
+ f"The '{column_type}' column could not be identified in the data. "
191
+ )
192
+ msg = (
193
+ "The data was unable to be parsed, most likely because it did not "
194
+ "match a recognized format. Supported data formats include those "
195
+ f"from the following banks: {', '.join(SUPPORTED_BANKS)}."
189
196
  )
197
+ abort(400, msg)
190
198
  return raw_column_indices
191
199
 
192
200
  def _determine_expenditure_sign(self, raw_data):
@@ -11,6 +11,10 @@
11
11
  --masthead-height: 50px;
12
12
  --masthead-color: #2b2b2b;
13
13
  --border-gray: #dddddd;
14
+ --button-background-base: #fbfbfb;
15
+ --button-background: linear-gradient(#fbfbfb, #efefef);
16
+ --button-block-background-base: #eeeeee;
17
+ --button-block-background: linear-gradient(#eeeeee, #e6e6e6);
14
18
  }
15
19
 
16
20
  html {
@@ -386,6 +390,35 @@ aside.sidebar {
386
390
  }
387
391
 
388
392
 
393
+ /*
394
+ * Provide styles for flashed messages
395
+ */
396
+ .flash.success,
397
+ .flash.warning,
398
+ .flash.error {
399
+ margin: 20px 0;
400
+ padding: 10px 20px;
401
+ border-radius: 10px;
402
+ box-shadow: 1px 1px 4px #ddd;
403
+ font-size: 0.9em;
404
+ }
405
+
406
+ .flash.success {
407
+ border: 1px solid #6fda6f;
408
+ background: linear-gradient(45deg, #e6efe1, #f5fef1);
409
+ }
410
+
411
+ .flash.warning {
412
+ border: 1px solid #dac96f;
413
+ background: linear-gradient(45deg, #efede1, #fefbf1);
414
+ }
415
+
416
+ .flash.error {
417
+ border: 1px solid #da6f6f;
418
+ background: linear-gradient(45deg, #f3e8e8, #fef1f1);
419
+ }
420
+
421
+
389
422
  /*
390
423
  * Provide styles for error pages
391
424
  */
@@ -459,8 +492,8 @@ aside.sidebar {
459
492
  border: 1px solid var(--border-gray);
460
493
  border-radius: 10px;
461
494
  box-shadow: 1px 1px 3px #bbbbbb;
462
- background-color: #eeeeee;
463
- background-image: linear-gradient(#eeeeee, #e6e6e6);
495
+ background-color: var(--button-block-background-base);
496
+ background-image: var(--button-block-background);
464
497
  color: inherit;
465
498
  }
466
499
 
@@ -546,15 +579,16 @@ aside.sidebar {
546
579
  left: 0;
547
580
  height: 100%;
548
581
  width: 100%;
582
+ display: flex;
583
+ flex-direction: column;
584
+ justify-content: center;
585
+ align-items: center;
549
586
  background-color: rgba(0, 0, 0, 0.75);
550
587
  z-index: 50; /* in front of content, but behind the header */
551
588
  }
552
589
 
553
590
  .modal {
554
- position: relative;
555
- top: 0%;
556
- left: 50%;
557
- transform: translate(-50%, 15%);
591
+ min-width: 200px;
558
592
  }
559
593
 
560
594
  .modal-box {
@@ -638,8 +672,8 @@ form input.button {
638
672
  align-items: center;
639
673
  margin-top: 20px;
640
674
  padding: 10px;
641
- background-color: #fbfbfb;
642
- background-image: linear-gradient(#fbfbfb, #efefef);
675
+ background-color: var(--button-background-base);
676
+ background-image: var(--button-background);
643
677
  color: #333333;
644
678
  font-weight:bold;
645
679
  text-align: center;
@@ -2564,6 +2598,21 @@ form .autocomplete-box .item.active {
2564
2598
  }
2565
2599
 
2566
2600
 
2601
+ /*
2602
+ * Customization for the 'Registration' page
2603
+ */
2604
+ .no-registration-notice {
2605
+ width: 60%;
2606
+ margin: 25px auto;
2607
+ padding: 10px;
2608
+ border: 1px solid var(--moneytree-leaves);
2609
+ border-radius: 10px;
2610
+ box-shadow: 0 0 10px var(--moneytree-leaves);
2611
+ background-color: #f5f5f5;
2612
+ text-align: center;
2613
+ }
2614
+
2615
+
2567
2616
  /*
2568
2617
  * Customization for the 'Profile' page
2569
2618
  */
@@ -3532,7 +3581,6 @@ form#pay #make-payment[type="submit"] #prompt {
3532
3581
  #statement-reconciliation.modal {
3533
3582
  width: 50%;
3534
3583
  min-width: 300px;
3535
- transform: translate(-50%, 100%);
3536
3584
  }
3537
3585
 
3538
3586
  #statement-reconciliation.modal .modal-box {
@@ -3604,13 +3652,145 @@ form#credit-transaction .merchant-field {
3604
3652
  /*
3605
3653
  * Customization for the transaction submission page
3606
3654
  */
3607
- #receipt {
3608
- width: 31%;
3655
+ #submission {
3656
+ display: flex;
3657
+ flex-direction: column;
3658
+ align-items: center;
3659
+ width: 50%;
3609
3660
  margin: auto;
3610
3661
  }
3662
+ @media screen and (max-width: 600px) {
3663
+ /* Mobile layout */
3664
+ #submission {
3665
+ width: 90%;
3666
+ }
3667
+ }
3668
+
3669
+ #submission-title {
3670
+ text-align: center;
3671
+ }
3672
+
3673
+ #receipt {
3674
+ width: 60%;
3675
+ min-width: 250px;
3676
+ max-width: 300px;
3677
+ margin: 40px auto;
3678
+ padding: 10px 20px 30px;
3679
+ box-shadow: 2px 2px 6px #bbbbbb;
3680
+ background-color: #fafafa;
3681
+ font-family: monospace;
3682
+ }
3683
+ @media screen and (max-width: 600px) {
3684
+ /* Mobile layout */
3685
+ #receipt {
3686
+ margin: 10px auto 30px;
3687
+ }
3688
+ }
3611
3689
 
3612
3690
  #receipt-title {
3691
+ margin-bottom: 20px;
3613
3692
  text-align: center;
3693
+ color: var(--silver-dollar);
3694
+ font-size: 10pt;
3695
+ font-weight: 400;
3696
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
3697
+ letter-spacing: 2px;
3698
+ text-transform: uppercase;
3699
+ }
3700
+
3701
+ #receipt .receipt-item {
3702
+ display: flex;
3703
+ margin: 15px 0;
3704
+ }
3705
+
3706
+ #receipt .receipt-item .receipt-key {
3707
+ font-weight: bold;
3708
+ }
3709
+
3710
+ #receipt #receipt-header {
3711
+ margin-bottom: 40px;
3712
+ }
3713
+
3714
+ #receipt #receipt-header #receipt-merchant {
3715
+ font-size: 14pt;
3716
+ justify-content: center;
3717
+ }
3718
+
3719
+ #receipt #receipt-header #receipt-date {
3720
+ font-size: 10pt;
3721
+ font-weight: normal;
3722
+ justify-content: center;
3723
+ }
3724
+
3725
+ #receipt .receipt-subtransaction {
3726
+ display: flex;
3727
+ gap: 10px 20px;
3728
+ margin: 10px 0;
3729
+ }
3730
+
3731
+ #receipt .receipt-subtransaction .note,
3732
+ #receipt #receipt-total .total {
3733
+ flex: 4;
3734
+ }
3735
+
3736
+ #receipt .receipt-subtransaction .amount,
3737
+ #receipt #receipt-total .amount {
3738
+ flex: 1;
3739
+ text-align: right;
3740
+ }
3741
+
3742
+ #receipt #receipt-card {
3743
+ margin-top: 40px;
3744
+ }
3745
+
3746
+ #receipt #receipt-card .receipt-item {
3747
+ flex-direction: column;
3748
+ }
3749
+
3750
+ #receipt #receipt-card .receipt-item .receipt-key {
3751
+ margin-bottom: 5px;
3752
+ }
3753
+
3754
+ #receipt #receipt-card .receipt-item .receipt-value {
3755
+ margin-left: 0;
3756
+ }
3757
+
3758
+ #submission-actions {
3759
+ display: flex;
3760
+ flex-direction: column;
3761
+ gap: 3px;
3762
+ align-items: center;
3763
+ width: 100%;
3764
+ max-width: 400px;
3765
+ }
3766
+ @media screen and (max-width: 600px) {
3767
+ /* Mobile layout */
3768
+ #submission-actions {
3769
+ gap: 5px;
3770
+ }
3771
+ }
3772
+
3773
+ #submission-actions .submission.button {
3774
+ width: 100%;
3775
+ padding: 3px 10px;
3776
+ border: 1px solid var(--border-gray);
3777
+ border-radius: 5px;
3778
+ background-color: var(--button-background-base);
3779
+ background-image: var(--button-background);;
3780
+ font-size: 12pt;
3781
+ text-decoration: none;
3782
+ box-sizing: border-box;
3783
+ }
3784
+ @media screen and (max-width: 600px) {
3785
+ /* Mobile layout */
3786
+ #submission-actions .submission.button {
3787
+ padding: 10px;
3788
+ font-size: 10pt;
3789
+ }
3790
+ }
3791
+
3792
+ #submission-actions .submission.button:hover {
3793
+ filter: brightness(0.98);
3614
3794
  }
3615
3795
 
3616
3796
 
@@ -0,0 +1,21 @@
1
+ {% extends 'layout.html' %}
2
+
3
+ {% block header %}
4
+ <h1>{% block title %}Change Password{% endblock %}</h1>
5
+ {% endblock %}
6
+
7
+ {% block content %}
8
+
9
+ <form id="change-password" method="post">
10
+ <p class="instructions">
11
+ Use this form to change the password for user: <b>{{ g.user.username }}</b>
12
+ </p>
13
+
14
+ <label for="password">Current Password</label>
15
+ <input type="password" name="current-password" id="current-password" required>
16
+ <label for="new-password">New Password</label>
17
+ <input type="password" name="new-password" id="new-password" required>
18
+ <input class="button" type="submit" value="Update" />
19
+ </form>
20
+
21
+ {% endblock %}
@@ -5,11 +5,13 @@
5
5
  {% endblock %}
6
6
 
7
7
  {% block content %}
8
- <form method="post">
8
+
9
+ <form id="log-in" method="post">
9
10
  <label for="username">Username</label>
10
11
  <input name="username" id="username" required>
11
12
  <label for="password">Password</label>
12
13
  <input type="password" name="password" id="password" required>
13
14
  <input class="button" type="submit" value="Log In" />
14
15
  </form>
16
+
15
17
  {% endblock %}
@@ -5,11 +5,21 @@
5
5
  {% endblock %}
6
6
 
7
7
  {% block content %}
8
- <form method="post">
9
- <label for="username">Username</label>
10
- <input name="username" id="username" required>
11
- <label for="password">Password</label>
12
- <input type="password" name="password" id="password" required>
13
- <input class="button" type="submit" value="Register" />
14
- </form>
8
+
9
+ {% if not config['REGISTRATION'] %}
10
+ <div class="no-registration-notice">
11
+ <b>Notice:</b> The app is not currently accepting new registrations.
12
+ </div>
13
+ {% endif %}
14
+
15
+ {% with input_status = 'required' if config['REGISTRATION'] else 'disabled' %}
16
+ <form id="register" method="post">
17
+ <label for="username">Username</label>
18
+ <input name="username" id="username" {{ input_status }}>
19
+ <label for="password">Password</label>
20
+ <input type="password" name="password" id="password" {{ input_status }}>
21
+ <input class="button" type="submit" value="Register" />
22
+ </form>
23
+ {% endwith %}
24
+
15
25
  {% endblock %}
@@ -25,8 +25,8 @@
25
25
  <div class=user-settings">
26
26
 
27
27
  <div class="password">
28
- <a style="color: gray;" href="{{ url_for('core.change_password') }}">
29
- Change password (coming soon)
28
+ <a style="color: gray;" href="{{ url_for('auth.change_password') }}">
29
+ Change password
30
30
  </a>
31
31
  </div>
32
32
 
@@ -12,83 +12,76 @@
12
12
 
13
13
  {% block content %}
14
14
 
15
- <div id="receipt">
15
+ <div id="submission">
16
16
 
17
- <p id="receipt-title">
17
+ <p id="submission-title">
18
18
  The transaction was saved successfully.
19
19
  </p>
20
- <br>
21
20
 
22
- <p>
23
- <b>Card:</b>
24
- {{ transaction.statement.card.account.bank.bank_name }} ****-{{ transaction.statement.card.last_four_digits }}
25
- </p>
26
-
27
- <p>
28
- <b>Date: </b>
29
- {{ transaction.transaction_date }}
30
- </p>
31
-
32
- <p>
33
- <b>Merchant: </b>
34
- {{ transaction.merchant }}
35
- </p>
36
-
37
- {% if subtransactions|length > 1 %}
38
- <p>
39
- <b>Total: </b>
40
- ${{ transaction.total|currency }}
41
- </p>
42
- {% endif %}
43
-
44
- {% for subtransaction in subtransactions %}
45
-
46
- <p>
47
- <b>Amount: </b>
48
- ${{ subtransaction['subtotal']|currency }}
49
- </p>
50
-
51
- <p>
52
- <b>Note: </b>
53
- {{ subtransaction['note'] }}
54
- </p>
55
-
56
- {% endfor %}
57
-
58
- <p>
59
- <b>Statement Date: </b>
60
- {{ transaction.statement.issue_date }}
61
- </p>
21
+ <div id="receipt">
22
+
23
+ <div id="receipt-header">
24
+ <h2 id="receipt-title">Transaction Submission</h2>
25
+ <h3 id="receipt-merchant" class="receipt-item">{{ transaction.merchant }}</h3>
26
+ <h4 id="receipt-date" class="receipt-item">{{ transaction.transaction_date }}</h4>
27
+ </div>
28
+
29
+ {% for subtransaction in subtransactions %}
30
+ <div class="receipt-subtransaction">
31
+ <div class="note">{{ subtransaction['note'] }}</div>
32
+ <div class="amount">${{ subtransaction['subtotal']|currency }}</div>
33
+ </div>
34
+ {% endfor %}
35
+
36
+ {% if subtransactions|length > 1 %}
37
+ <div id="receipt-total" class="receipt-item">
38
+ <div class="total receipt-key">Total:</div>
39
+ <div class="amount">${{ transaction.total|currency }}</div>
40
+ </div>
41
+ {% endif %}
42
+
43
+ <div id="receipt-card">
44
+ <div class="receipt-item">
45
+ <div class="receipt-key">Card:</div>
46
+ <div class="receipt-value">{{ transaction.statement.card.account.bank.bank_name }} ****-{{ transaction.statement.card.last_four_digits }}</div>
47
+ </div>
48
+ <div class="receipt-item">
49
+ <div class="receipt-key">Statement Date:</div>
50
+ <div class="receipt-value">{{ transaction.statement.issue_date }}</div>
51
+ </div>
52
+ </div>
53
+
54
+
55
+ </div>
62
56
 
63
57
  {% if g.user %}
64
- <br>
65
- <a href="{{ url_for('credit.load_statement_details', statement_id=transaction.statement_id) }}">
66
- See the statement for this transaction
67
- </a>
68
- <br>
69
- <a href="{{ url_for('credit.load_transactions') }}">
70
- See transaction history
71
- </a>
72
- <br>
73
- <a href="{{ url_for('credit.update_transaction', transaction_id=transaction.id) }}">
74
- Update this transaction
75
- </a>
76
- <br>
77
- <a href="{{ url_for('credit.add_transaction') }}">
78
- Create a new transaction
79
- </a>
80
- <br>
81
- <a href="{{ url_for('credit.add_transaction', card_id=transaction.statement.card_id, statement_id=transaction.statement_id) }}">
82
- Create a new transaction on this statement
83
- </a>
84
- {% with reconciliation_info = session.get('reconciliation_info', None) %}
85
- {% if reconciliation_info %}
86
- <br>
87
- <a href="{{ url_for('credit.load_statement_reconciliation_details', statement_id=reconciliation_info[0]) }}">
88
- Return to the in-progress statement reconciliation
89
- </a>
90
- {% endif %}
91
- {% endwith %}
58
+ <div id="submission-actions">
59
+ <a class="submission button" href="{{ url_for('credit.update_transaction', transaction_id=transaction.id) }}">
60
+ Update this transaction
61
+ </a>
62
+ <a class="submission button" href="{{ url_for('credit.add_transaction') }}">
63
+ Create a new transaction
64
+ </a>
65
+ <a class="submission button" href="{{ url_for('credit.add_transaction', card_id=transaction.statement.card_id, statement_id=transaction.statement_id) }}">
66
+ Create a new transaction on this statement
67
+ </a>
68
+ <a class="submission button" href="{{ url_for('credit.load_statement_details', statement_id=transaction.statement_id) }}">
69
+ See the statement for this transaction
70
+ </a>
71
+ <a class="submission button" href="{{ url_for('credit.load_statements') }}">
72
+ See statement history
73
+ </a>
74
+ <a class="submission button" href="{{ url_for('credit.load_transactions') }}">
75
+ See transaction history
76
+ </a>
77
+ {% with reconciliation_info = session.get('reconciliation_info', None) %}
78
+ {% if reconciliation_info %}
79
+ <a class="submission button" href="{{ url_for('credit.load_statement_reconciliation_details', statement_id=reconciliation_info[0]) }}">
80
+ Return to the in-progress statement reconciliation
81
+ </a>
82
+ {% endif %}
83
+ {% endwith %}
84
+ </div>
92
85
  {% endif %}
93
86
 
94
87
  </div>
@@ -91,8 +91,8 @@
91
91
  {% block header %}{% endblock %}
92
92
  </header>
93
93
 
94
- {% for message in get_flashed_messages() %}
95
- <div class="flash">{{ message }}</div>
94
+ {% for category, message in get_flashed_messages(with_categories=True) %}
95
+ <div class="flash {{ category }}">{{ message }}</div>
96
96
  {% endfor %}
97
97
 
98
98
  {% block content %}{% endblock %}
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: monopyly
3
- Version: 1.5.0
3
+ Version: 1.5.1
4
4
  Summary: A homemade personal finance manager.
5
5
  Project-URL: Download, https://pypi.org/project/monopyly
6
6
  Project-URL: Homepage, http://monopyly.com
@@ -23,16 +23,16 @@ Classifier: Topic :: Office/Business :: Financial
23
23
  Classifier: Topic :: Office/Business :: Financial :: Accounting
24
24
  Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
25
25
  Requires-Python: <3.11,>=3.10
26
- Requires-Dist: authanor==1.1.0
26
+ Requires-Dist: authanor==1.1.1
27
27
  Requires-Dist: flask-wtf==1.2.1
28
28
  Requires-Dist: flask==3.0.3
29
29
  Requires-Dist: fuisce==1.0.2
30
- Requires-Dist: gunicorn==22.0.0
31
- Requires-Dist: markdown==3.6
32
- Requires-Dist: nltk==3.8.1
30
+ Requires-Dist: gunicorn==23.0.0
31
+ Requires-Dist: markdown==3.7
32
+ Requires-Dist: nltk==3.9.1
33
33
  Requires-Dist: python-dateutil==2.9.0
34
- Requires-Dist: rich==13.7.1
35
- Requires-Dist: sqlalchemy==2.0.29
34
+ Requires-Dist: rich==13.8.1
35
+ Requires-Dist: sqlalchemy==2.0.35
36
36
  Description-Content-Type: text/markdown
37
37
 
38
38
  <div id="header">
@@ -1,11 +1,11 @@
1
- monopyly/CHANGELOG.md,sha256=Jk6ffjpSKjzEAD-g3u9U6Iwo4Ip7XayERdLQCQdcyP0,7680
1
+ monopyly/CHANGELOG.md,sha256=gRCnpb-vMJfzcTbFQ0dXXHsAdMy6O2ZpM52JZpkBiYM,8167
2
2
  monopyly/README.md,sha256=cm1wli7E-xybYoJr-tsrNgfIO6TPrLsraMUSO26D6xk,9028
3
3
  monopyly/__init__.py,sha256=2UPhNvpvhuafUZwgSGJEIaOj9LIXzrRO-S8dfB4-FeQ,2203
4
- monopyly/_version.py,sha256=PJLsF_U86F0-FKJiqQYvlSYbBBNG6Ou_GDrNAk-8CDw,160
5
- monopyly/auth/actions.py,sha256=zFDb_EATufsJMzQ4rX0bzlme8e62mXFpYtQvc_k0jEc,375
4
+ monopyly/_version.py,sha256=W6YuN1JOd6M-rSt9HDXK91AutRDYXTjJT_LQg3rCsjk,411
5
+ monopyly/auth/actions.py,sha256=uwXg0LVz3QVJxKkiI-YCJMT8OSjUK7R-aEy-XTM0Ghs,702
6
6
  monopyly/auth/blueprint.py,sha256=sFbmTvSBhjv1pn2gJLH5J6zanPLuemmWbgEj5ZKCiTY,244
7
- monopyly/auth/routes.py,sha256=mODJHrdFhffusfbYwQ_wpIY7eAE7Gh7irosIef4Mluc,2902
8
- monopyly/auth/tools.py,sha256=hifCHBN06Ht-FnMhJPzzj-xjMN8uwbeJ-azRVjnT99c,801
7
+ monopyly/auth/routes.py,sha256=bGuKDnyo0t918Gx_OeCbN51pJFhUsPyZ-TSPUCFuMnY,3375
8
+ monopyly/auth/tools.py,sha256=CDMcvRY0A-cJxvKUKOUkDzZHDxveHl-8TzhKyOzsXEs,792
9
9
  monopyly/banking/accounts.py,sha256=yYlXvyhLBQoBZYr_WMk6_3nxlhwU4NEkCYKv5hHFjcM,9828
10
10
  monopyly/banking/actions.py,sha256=NvButozWJNRuNIiyM-Gy88XLTpIjNcEGSQcLMfEwOUg,3258
11
11
  monopyly/banking/banks.py,sha256=X1seKJrec-o8z-bM0wVTR42vMx1q5A9Lf2jNUtINlrQ,1745
@@ -21,7 +21,7 @@ monopyly/common/utils.py,sha256=BjXhfNXGWkAPt-ebldvmZ2z00P8MhKJzjxrJJ6mzRQY,5867
21
21
  monopyly/common/forms/__init__.py,sha256=6iFTlcoaQmCbrjS1KfFhWXQUfyqnA-vKPMVxLyJMlXY,120
22
22
  monopyly/common/forms/_forms.py,sha256=wlLY9pBtFYcOEnFL_dnt62JFtbs88u_B9LXN2eTe_PA,10130
23
23
  monopyly/common/forms/fields.py,sha256=XgvkfszpUAZyIs-osHGFADmzuo0Ni_e78NXtG-grBdI,4082
24
- monopyly/common/forms/utils.py,sha256=awZOtWaohzSW_SZrXhUpJBY1AXPnKBFTG2cqim22Db0,5676
24
+ monopyly/common/forms/utils.py,sha256=QbA6253xL4J01DQNQXL-BM13MgAr_HAM93H8a9Nwh6k,5660
25
25
  monopyly/common/forms/validators.py,sha256=C5_NN8ktvBw6MrSXwv_x9_SQohCNmnp4brNQFELHBlI,1205
26
26
  monopyly/config/__init__.py,sha256=pW2lHNhsdM2gRjLAWumnLUHZMAOEdprX4aXaONwo6AY,73
27
27
  monopyly/config/default_settings.py,sha256=jtWz4ElasnAbfBCiRfbsYyy--L6N6jTOGRh3PNWnVj4,1829
@@ -32,19 +32,19 @@ monopyly/core/context_processors.py,sha256=ByQGQpOJIpxG3WM3Kv34LbBY0H8HVeR81TDYk
32
32
  monopyly/core/errors.py,sha256=xF4gA3Hv99op3EMV_f2CskHWVLrV-9ckaHKJMWR2h7o,200
33
33
  monopyly/core/filters.py,sha256=uBkNjQyqW-9RkiQG23vngnI1MZXFeh4Rhe5MQqklAEQ,1284
34
34
  monopyly/core/internal_transactions.py,sha256=PImeViMU9rdDDPLXld1xC6bdAoulzRDr0zci4pUQY8I,459
35
- monopyly/core/routes.py,sha256=ADF7LB3hhvwPwIqc5l46sliziM7ZgGXh5vFwKzxlkGA,2747
35
+ monopyly/core/routes.py,sha256=FhXL8JrqsumxdUiGsmzSG7fim4Kz5rDxr0Az-3SmI90,2639
36
36
  monopyly/credit/accounts.py,sha256=otQmuTDJZYX868EZl80n89XlzfiwlnBkfSXZa65rSqI,1898
37
37
  monopyly/credit/actions.py,sha256=eNtowA16zfP8gmo93H9_LbARJ0JKxhihqy3OhX0bwpQ,7211
38
38
  monopyly/credit/blueprint.py,sha256=XbrMhtyCp2732uWPB2kjn_W8P8pH8RVTwo9P8Pt--Is,251
39
39
  monopyly/credit/cards.py,sha256=9Ug51aY2-G8SzooLC-FemRNeTBLd9JOpXHFW74EcdLU,5614
40
- monopyly/credit/forms.py,sha256=cs7-uL8Q9mUPUCsPVGo32gYwJpF3wfsHwmDfLBQWNnI,13636
41
- monopyly/credit/routes.py,sha256=qghi2_rx-IHFsVN99Ovd2hjCxC89vmE-eSTiCnjqJ44,21408
40
+ monopyly/credit/forms.py,sha256=aGARysyBNY04hF43IgbwZfBB6ByG51oqbOivmBXOxsk,13614
41
+ monopyly/credit/routes.py,sha256=weq0cdSB9eehdj1huTHBOjjCEtr3sel_82GX1BTe6EM,21977
42
42
  monopyly/credit/statements.py,sha256=lRcAgcr9kPnh5GCE6McWfOdY8uCgmg2j2bikSioR3HI,7219
43
43
  monopyly/credit/transactions/__init__.py,sha256=1Exn6T--HtwNCHzS_hDJ0ba7qCB7w_8C-yY3KSxvFKg,165
44
44
  monopyly/credit/transactions/_transactions.py,sha256=V4gLI6HpiuNppNV5xmJBsPU1YL59HyiNLbh3U2LAvbU,9222
45
45
  monopyly/credit/transactions/activity/__init__.py,sha256=Iq5KLXdCf1UCfM9qDRXbYCaz1NmgJMCApK4kGDNJISo,139
46
46
  monopyly/credit/transactions/activity/data.py,sha256=6ND7fz5L1aH2g_2drGz2Xf7maUaJ6RyItLdBeWXBIlA,5849
47
- monopyly/credit/transactions/activity/parser.py,sha256=mvDq6jpLs1Bbma0G-YP1G5prpAltgMd3YTzKg46TNc0,11166
47
+ monopyly/credit/transactions/activity/parser.py,sha256=yBEP3jG36iixhA0KN8TXdvE55Jp54L6TMR8xlOjqJMY,11624
48
48
  monopyly/credit/transactions/activity/reconciliation.py,sha256=cHcqXAp63hHrNkeb3WoX0bGnJbzrOfe6uKF6L2CF9y0,18798
49
49
  monopyly/database/__init__.py,sha256=tlZsXmzH8hB5m_RASbKTjPRqLel-xbymqrzo0TPoFCk,3402
50
50
  monopyly/database/models.py,sha256=nnO6dyf7IJOp3dwIoPrWECnWlRPdocfrMqSD_tT1OSg,15319
@@ -52,7 +52,7 @@ monopyly/database/preloads.sql,sha256=KBS_WYRofFdOVN-IgHzZhfyJrY6ZOwHeExN-fQr3ht
52
52
  monopyly/database/schema.sql,sha256=cdl9b5CnYrnQ-lck147GtwArx_JJbX1vF9YYLivTeyw,4660
53
53
  monopyly/database/views.sql,sha256=UTO2QRkVTfQ12bMVkR-O6Qv80DvYo8hngPHn2A1_1F8,3853
54
54
  monopyly/static/jquery-3.7.0.min.js,sha256=2Pmvv0kuTBOenSvLm6bvfBSSHrUJ-3A7x6P5Ebd07_g,87462
55
- monopyly/static/css/style.css,sha256=umkVrUYseu8cPuf2Btt9vs-rTJjeIRo7XcQA2WUs2iM,70040
55
+ monopyly/static/css/style.css,sha256=_XOOIINtBFXZXFZZD1SKcTZkvsHegkBDdlX9zNew7ZQ,73560
56
56
  monopyly/static/favicon/browserconfig.xml,sha256=Zt_AVOxiritWWXoUwPsHpx4vu4kM_butdFVzoYCYbM8,315
57
57
  monopyly/static/favicon/favicon-114.png,sha256=kjElVFiix-kFCMdADkLpJsi8kL3GDsFm85oJhyCH-C0,20601
58
58
  monopyly/static/favicon/favicon-120.png,sha256=g4uzHMdW0MlJhcgWfrOsj2MB2D-HdLa4t60_hvyICkM,22077
@@ -149,9 +149,10 @@ monopyly/static/js/modules/manage-overlays.js,sha256=nl-UlmZh22RnX648Nb_ybpt0Wfi
149
149
  monopyly/static/js/modules/manage-subforms.js,sha256=-yKA7l8ZI0294auTI307LrKkw_Bl6I8suwK4VLuLhMc,2197
150
150
  monopyly/static/js/modules/update-database-widget.js,sha256=S67hmqaGwzbPy94IjYrag0ZPOur4r5y_tb3_5t7xuYI,1581
151
151
  monopyly/static/js/modules/update-display-ajax.js,sha256=MJBiRMmeIHRG7lLbqIcXkUecKNNMFFVJXwhs_N8LKl0,878
152
- monopyly/templates/layout.html,sha256=DyjxrIt5iz2LYHebaU1p1Y6NPEhMsGY-BVBjg8quUBY,5734
153
- monopyly/templates/auth/login.html,sha256=aZwaHBiKtQa_ZkVBqLUQIbwQdEAJNwcyYNjFQcRp-k0,443
154
- monopyly/templates/auth/register.html,sha256=G6VxfYPIbXQco6Z_1PVQQ40vNWgG3PScmN20npx88yQ,447
152
+ monopyly/templates/layout.html,sha256=a1JQCOvlOHP1zNd64EmXrUaGQuhmrajeQCBPEVZgopE,5779
153
+ monopyly/templates/auth/change_password.html,sha256=yf8Zg2QYmBd9U-MHQXoI8d6pwrnWokDRjbVMxMIJTdU,652
154
+ monopyly/templates/auth/login.html,sha256=Cr16HB8FkYb7jT4ueCsEoOtSM84Lh4e9Jombsb6UPr8,457
155
+ monopyly/templates/auth/register.html,sha256=rZh1IUhCN_67vcSHPHm4LS1rJTo8kcaNc2EEnCTrmoQ,774
155
156
  monopyly/templates/banking/account_page.html,sha256=Y2lZrzXFdykDkkPK9-FktGPq6i-94hV_Me1BM-MzxXU,2002
156
157
  monopyly/templates/banking/account_summaries.html,sha256=GCMIFF6bYOwzPyw8gxKo8mAJ7QzmvyB2d8moEgzqki4,1139
157
158
  monopyly/templates/banking/account_summaries_page.html,sha256=puBlB52WMZR_Dhz6_rSLomNa9QSChsItsshrKT5Aixs,1113
@@ -182,7 +183,7 @@ monopyly/templates/common/transactions_table/transaction_expanded.html,sha256=mL
182
183
  monopyly/templates/common/transactions_table/transactions.html,sha256=a0kUH1tZB9Ilsvf1MpOtRJiHB9ddFJq7q_-zPtpO4Is,531
183
184
  monopyly/templates/core/credits.html,sha256=5XO5foZ_J1RA42BEOzN20bZRcWm9xhZe125BCKudoUc,1166
184
185
  monopyly/templates/core/index.html,sha256=02iQ1feV47EIG2nPGR4n2ANp8L1Jg1DEC52ievARnm8,4684
185
- monopyly/templates/core/profile.html,sha256=26DZ0fXogpZ9XEpXo5ChaRen6sMiKkVh8lN_Xd2Ffcc,2363
186
+ monopyly/templates/core/profile.html,sha256=uxHMs3hOta_ms3ZZ8JdariobFlvq4SU_cLNbhYsXJ78,2349
186
187
  monopyly/templates/core/story.html,sha256=iEKfX1aHxXrstAeeQ6J8Gnvg1zGt95c-iLELUhG-AtE,3331
187
188
  monopyly/templates/core/errors/400.html,sha256=whUYO3b6UIQWfN8y563umccU8GLxqXu0A5nb5uNM-XY,152
188
189
  monopyly/templates/core/errors/401.html,sha256=3HH9TSDZfhyxZVcVnT0PNe524ADvEHIj6BsllQYFFOQ,154
@@ -203,7 +204,7 @@ monopyly/templates/credit/statement_summary.html,sha256=4DkqlN1yKIhFYFUSxmbf997J
203
204
  monopyly/templates/credit/statements.html,sha256=ndCGe4xEj9aFfKPSrr1fnTyKKU-KxGRLq0iJTeJnDvc,1783
204
205
  monopyly/templates/credit/statements_page.html,sha256=s_CSiRO8iTcCoEPVRF2TEOG2XNOeuGF1BSiJGhrH5O0,1458
205
206
  monopyly/templates/credit/tags_page.html,sha256=mKm-QxuicO5MO12JOQy7tuc8Bd9fnnKiTSHDmMP5mTI,662
206
- monopyly/templates/credit/transaction_submission_page.html,sha256=2N9oycKIPrXWXBOkzXcaRhiSsj46l1KEsRUyJjknm8o,2289
207
+ monopyly/templates/credit/transaction_submission_page.html,sha256=FNqvt0WB59_80GhsUrPTEbPs_ydGF0G_fnE1T13_g_A,3136
207
208
  monopyly/templates/credit/transactions_page.html,sha256=4Tx2yUGPUIRfz0M3vAQqjcfHAPvsVXdiD0k9N1O3B0Q,2391
208
209
  monopyly/templates/credit/card_form/card_form.html,sha256=xF7x1RBQyAfKYuW_cRxwhKhug4tum-3s0YHyP0ovhFs,1666
209
210
  monopyly/templates/credit/card_form/card_form_page_new.html,sha256=9ayX0dUEAbSSPVL3vIKIBJYkN8odL_KHOfq3fl6C8yw,525
@@ -225,9 +226,9 @@ monopyly/templates/credit/transactions_table/condensed_row_content.html,sha256=T
225
226
  monopyly/templates/credit/transactions_table/expanded_row_content.html,sha256=SwS-YVDQDv0rHK2g_vLs0RWePKKo5972UEOG1JwuSVI,2061
226
227
  monopyly/templates/credit/transactions_table/transaction_field_titles.html,sha256=km-3YEDJaygwcKV-rwYnrE7xF8u4Z7jCBsk0Ia-LO-M,758
227
228
  monopyly/templates/credit/transactions_table/transactions.html,sha256=tcppv0qNV-Qq6U5jfxoNyGKPXmyV9h9nR6rw4jXfBsY,481
228
- monopyly-1.5.0.dist-info/METADATA,sha256=8DXxPJ5UuS1duTBFPb0qJiRcEYRpx4AOG9ETPEglz8U,10980
229
- monopyly-1.5.0.dist-info/WHEEL,sha256=Fd6mP6ydyRguakwUJ05oBE7fh2IPxgtDN9IwHJ9OqJQ,87
230
- monopyly-1.5.0.dist-info/entry_points.txt,sha256=_GmCja7NEqYHlGlVbz4rThqk0SIOmFUkiE8mN1dwgdY,58
231
- monopyly-1.5.0.dist-info/licenses/COPYING,sha256=5X8cMguM-HmKfS_4Om-eBqM6A1hfbgZf6pfx2G24QFI,35150
232
- monopyly-1.5.0.dist-info/licenses/LICENSE,sha256=94rIicMccmTPhqXiRLV9JsU8P2ocMuEUUtUpp6LPKiE,253
233
- monopyly-1.5.0.dist-info/RECORD,,
229
+ monopyly-1.5.1.dist-info/METADATA,sha256=eeifCD3BKPYr57ZJG3czCwzKzWpryfk3Q8_1swYqZAc,10980
230
+ monopyly-1.5.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
231
+ monopyly-1.5.1.dist-info/entry_points.txt,sha256=_GmCja7NEqYHlGlVbz4rThqk0SIOmFUkiE8mN1dwgdY,58
232
+ monopyly-1.5.1.dist-info/licenses/COPYING,sha256=5X8cMguM-HmKfS_4Om-eBqM6A1hfbgZf6pfx2G24QFI,35150
233
+ monopyly-1.5.1.dist-info/licenses/LICENSE,sha256=94rIicMccmTPhqXiRLV9JsU8P2ocMuEUUtUpp6LPKiE,253
234
+ monopyly-1.5.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.13.0
2
+ Generator: hatchling 1.27.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any