mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-06 13:01:50 +02:00
Add history page and endpoints
This commit is contained in:
parent
b9f5658aae
commit
c95e50aba5
5 changed files with 318 additions and 2 deletions
|
@ -320,7 +320,7 @@ footer .footer-left {
|
||||||
background: url("../images/see.png") no-repeat right;
|
background: url("../images/see.png") no-repeat right;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bill_table, #monthly_stats {
|
#bill_table, #monthly_stats, #history_table {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
@ -356,6 +356,36 @@ footer .footer-left {
|
||||||
background: url("../images/see.png") no-repeat right;
|
background: url("../images/see.png") no-repeat right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history_icon > .delete,
|
||||||
|
.history_icon > .add,
|
||||||
|
.history_icon > .edit {
|
||||||
|
font-size: 0px;
|
||||||
|
display: block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin: 2px;
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-top: 3px;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history_icon > .delete {
|
||||||
|
background: url("../images/delete.png") no-repeat right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history_icon > .edit {
|
||||||
|
background: url("../images/edit.png") no-repeat right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history_icon > .add {
|
||||||
|
background: url("../images/add.png") no-repeat right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history_text {
|
||||||
|
display: table-cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.balance .balance-value {
|
.balance .balance-value {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
@ -516,3 +546,7 @@ footer .icon svg {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#history_warnings {
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
BIN
ihatemoney/static/images/add.png
Normal file
BIN
ihatemoney/static/images/add.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 264 B |
235
ihatemoney/templates/history.html
Normal file
235
ihatemoney/templates/history.html
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
{% extends "sidebar_table_layout.html" %}
|
||||||
|
|
||||||
|
{% macro change_to_logging_preference(event) %}
|
||||||
|
{% if event.val_after == LoggingMode.DISABLED %}
|
||||||
|
{% if event.val_before == LoggingMode.ENABLED %}
|
||||||
|
{{ _("Disabled Project History") }}
|
||||||
|
{% elif event.val_before == LoggingMode.RECORD_IP %}
|
||||||
|
{{ _("Disabled Project History & IP Address Recording") }}
|
||||||
|
{% else %}
|
||||||
|
{{ _("Disabled Project History & IP Address Recording") }}
|
||||||
|
{% endif %}
|
||||||
|
{% elif event.val_after == LoggingMode.ENABLED %}
|
||||||
|
{% if event.val_before == LoggingMode.DISABLED %}
|
||||||
|
{{ _("Enabled Project History") }}
|
||||||
|
{% elif event.val_before == LoggingMode.RECORD_IP %}
|
||||||
|
{{ _("Disabled IP Address Recording") }}
|
||||||
|
{% else %}
|
||||||
|
{{ _("Enabled Project History") }}
|
||||||
|
{% endif %}
|
||||||
|
{% elif event.val_after == LoggingMode.RECORD_IP %}
|
||||||
|
{% if event.val_before == LoggingMode.DISABLED %}
|
||||||
|
{{ _("Enabled Project History & IP Address Recording") }}
|
||||||
|
{% elif event.val_before == LoggingMode.ENABLED %}
|
||||||
|
{{ _("Enabled IP Address Recording") }}
|
||||||
|
{% else %}
|
||||||
|
{{ _("Enabled Project History & IP Address Recording") }}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{# Should be unreachable #}
|
||||||
|
{{ _("History Settings Changed") }}
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro describe_object(event) %}{{ event.object_type }} <em class="font-italic">{{ event.object_desc }}</em>{% endmacro %}
|
||||||
|
|
||||||
|
{% macro simple_property_change(event, localized_property_name, from=True) %}
|
||||||
|
{{ describe_object(event) }}:
|
||||||
|
{{ localized_property_name }} {{ _("changed") }}
|
||||||
|
{% if from %}{{ _("from") }} <em class="font-italic">{{ event.val_before }}</em> {% endif %}
|
||||||
|
{{ _("to") }} <em class="font-italic">{{ event.val_after }}</em>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
{% macro owers_changed(event, add) %}
|
||||||
|
{{ describe_object(event) }}: {% if add %}{{ _("Added") }}{% else %}{{ _("Removed") }}{% endif %}
|
||||||
|
{% if event.val_after|length > 1 %}
|
||||||
|
{% for name in event.val_after %}
|
||||||
|
<em class="font-italic">{{ name }}</em>{% if event.val_after|length > 2 and loop.index != event.val_after|length %},{% endif %}
|
||||||
|
{% if loop.index == event.val_after|length - 1 %} {{ _("and") }} {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<em class="font-italic">{{ event.val_after[0] }}</em>
|
||||||
|
{% endif %}
|
||||||
|
{% if add %}{{ _("to") }}{% else %}{{ _("from") }}{% endif %}
|
||||||
|
{{ _("owers list") }}
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% block sidebar %}
|
||||||
|
<div id="table_overflow">
|
||||||
|
<table class="balance table">
|
||||||
|
<thead>
|
||||||
|
<tr class="d-none d-md-table-row">
|
||||||
|
<th>{{ _("Who?") }}</th>
|
||||||
|
<th class="balance-value">{{ _("Balance") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{% set balance = g.project.balance %}
|
||||||
|
{% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %}
|
||||||
|
<tr id="bal-member-{{ member.id }}" action={% if member.activated %}delete{% else %}reactivate{% endif %}>
|
||||||
|
<td class="balance-name">{{ member.name }}</td>
|
||||||
|
<td class="balance-value {% if balance[member.id]|round(2) > 0 %}positive{% elif balance[member.id]|round(2) < 0 %}negative{% endif %}">
|
||||||
|
{% if balance[member.id]|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if current_log_pref == LoggingMode.DISABLED or (current_log_pref != LoggingMode.RECORD_IP and any_ip_addresses) %}
|
||||||
|
<div id="history_warnings" class="card card-body bg-light">
|
||||||
|
{% if current_log_pref == LoggingMode.DISABLED %}
|
||||||
|
<p>
|
||||||
|
<i>{{ _("This project has history disabled. New actions won't appear below. You can enable history on the") }}</i>
|
||||||
|
<a href="{{ url_for(".edit_project") }}">{{ _("settings page") }}</a>
|
||||||
|
</p>
|
||||||
|
{% if history %}
|
||||||
|
<p><i>{{ _("The table below reflects actions recorded prior to disabling project history. You can ") }}
|
||||||
|
<a href="#" data-toggle="modal" data-keyboard="false" data-target="#confirm-erase">{{ _("clear project history") }}</a> {{ _("to remove them.") }}</i></p>
|
||||||
|
<!-- Modal -->
|
||||||
|
<div id="confirm-erase" class="modal fade show" role="dialog">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">{{ _('Delete Confirmation') }}</h3>
|
||||||
|
<a href="#" class="close" data-dismiss="modal">×</a>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>{{ _("Are you sure you want to erase all history for this project? This action cannot be undone.") }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ _("Close") }}</button>
|
||||||
|
<form action="{{ url_for(".erase_history") }}" method="post">
|
||||||
|
<input type="submit" class="btn btn-danger" value="{{ _("Confirm Delete") }}" name="{{ _("Confirm Delete") }}"/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if current_log_pref != LoggingMode.RECORD_IP and any_ip_addresses %}
|
||||||
|
<p><i>{{ _("Some entries below contain IP addresses, even though this project has IP recording disabled. ") }}
|
||||||
|
<a href="#" data-toggle="modal" data-keyboard="false" data-target="#confirm-ip-delete">{{ _(" Delete stored IP addresses ") }}</a></i></p>
|
||||||
|
<!-- Modal -->
|
||||||
|
<div id="confirm-ip-delete" class="modal fade show" role="dialog">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">{{ _('Confirm Remove IP Adresses') }}</h3>
|
||||||
|
<a href="#" class="close" data-dismiss="modal">×</a>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>{{ _("Are you sure you want to delete all recorded IP addresses from this project?
|
||||||
|
The rest of the project history will be unaffected. This action cannot be undone.") }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ _("Close") }}</button>
|
||||||
|
<form action="{{ url_for(".strip_ip_addresses") }}" method="post">
|
||||||
|
<input type="submit" class="btn btn-danger" value="{{ _("Confirm Delete") }}" name="{{ _("Confirm Delete") }}"/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if history %}
|
||||||
|
<table id="history_table" class="split_bills table table-striped">
|
||||||
|
<thead><tr>
|
||||||
|
<th style="width: 20%">{{ _("Time") }}</th>
|
||||||
|
<th style="width: 60%">{{ _("Event") }}</th>
|
||||||
|
<th style="width: 20%">
|
||||||
|
<span data-toggle="tooltip" title="{{_('IP address recording can be') }}
|
||||||
|
{% if current_log_pref != LoggingMode.RECORD_IP %}
|
||||||
|
{{ _("enabled") }}{% else %}{{ _("disabled") }}{% endif %}
|
||||||
|
{{ _('on the Settings page') }}">
|
||||||
|
{{ _("From IP") }}</span></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event in history %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ momentjs(event.time).calendar() }}</td>
|
||||||
|
<td >
|
||||||
|
<div class="history_icon">
|
||||||
|
<i {% if event.operation_type == OperationType.INSERT %}
|
||||||
|
class="add"
|
||||||
|
{% elif event.operation_type == OperationType.UPDATE %}
|
||||||
|
class="edit"
|
||||||
|
{% elif event.operation_type == OperationType.DELETE %}
|
||||||
|
class="delete"
|
||||||
|
{% endif %}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<div class="history_text">
|
||||||
|
{% if event.operation_type == OperationType.INSERT %}
|
||||||
|
{{ event.object_type }} <em class="font-italic">{{ event.object_desc }}</em> {{ _("added") }}
|
||||||
|
{% elif event.operation_type == OperationType.UPDATE %}
|
||||||
|
{% if event.object_type == _("Project") %}
|
||||||
|
{% if event.prop_changed == "password" %}
|
||||||
|
{{ _("Project private code changed") }}
|
||||||
|
{% elif event.prop_changed == "logging_preference" %}
|
||||||
|
{{ change_to_logging_preference(event) }}
|
||||||
|
{% elif event.prop_changed == "name" %}
|
||||||
|
{{ _("Project renamed to ") }} <em class="font-italic">{{ event.object_desc }}</em>
|
||||||
|
{% elif event.prop_changed == "contact_email" %}
|
||||||
|
{{ _("Project contact email changed to ") }} <em class="font-italic">{{ event.val_after }}</em>
|
||||||
|
{% else %}
|
||||||
|
{{ _("Project settings modified") }}
|
||||||
|
{% endif %}
|
||||||
|
{% elif event.prop_changed == "activated" %}
|
||||||
|
{{ event.object_type }} <em class="font-italic">{{ event.object_desc }}</em>
|
||||||
|
{% if event.val_after == False %}{{ _("deactivated") }}{% else %}{{ _("reactivated") }}{% endif %}
|
||||||
|
{% elif event.prop_changed == "name" or event.prop_changed == "what" %}
|
||||||
|
{{ describe_object(event) }} {{ _("renamed") }} {{ _("to") }} <em class="font-italic">{{ event.val_after }}</em>
|
||||||
|
{% elif event.prop_changed == "weight" %}
|
||||||
|
{{ simple_property_change(event, _("Weight")) }}
|
||||||
|
{% elif event.prop_changed == "external_link" %}
|
||||||
|
{{ describe_object(event) }}: {{ _("External link changed to") }}
|
||||||
|
<a href="{{ event.val_after }}" class="font-italic">{{ event.val_after }}</a>
|
||||||
|
{% elif event.prop_changed == "owers_added" %}
|
||||||
|
{{ owers_changed(event, True)}}
|
||||||
|
{% elif event.prop_changed == "owers_removed" %}
|
||||||
|
{{ owers_changed(event, False)}}
|
||||||
|
{% elif event.prop_changed == "payer" %}
|
||||||
|
{{ simple_property_change(event, _("Payer"))}}
|
||||||
|
{% elif event.prop_changed == "amount" %}
|
||||||
|
{{ simple_property_change(event, _("Amount")) }}
|
||||||
|
{% elif event.prop_changed == "date" %}
|
||||||
|
{{ simple_property_change(event, _("Date")) }}
|
||||||
|
{% else %}
|
||||||
|
{{ describe_object(event) }} {{ _("modified") }}
|
||||||
|
{% endif %}
|
||||||
|
{% elif event.operation_type == OperationType.DELETE %}
|
||||||
|
{{ event.object_type }} <em class="font-italic">{{ event.object_desc }}</em> {{ _("removed") }}
|
||||||
|
{% else %}
|
||||||
|
{# Should be unreachable #}
|
||||||
|
{{ describe_object(event) }} {{ _("changed in a unknown way") }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{% if event.ip %}{{ event.ip }}{% else %} -- {% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="py-3 d-flex justify-content-center empty-bill">
|
||||||
|
<div class="card d-inline-flex p-2">
|
||||||
|
<div class="card-body text-center text-muted">
|
||||||
|
<i class="icon icon-white hand-holding-heart">{{ static_include("images/hand-holding-heart.svg") | safe }}</i>
|
||||||
|
<h3>{{ _('Nothing to list')}}</h3>
|
||||||
|
<p>
|
||||||
|
{{ _("Someone probably") }}<br />
|
||||||
|
{{ _("cleared the project history.") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -12,6 +12,7 @@
|
||||||
<script src="{{ url_for("static", filename="js/popper.min.js") }}"></script>
|
<script src="{{ url_for("static", filename="js/popper.min.js") }}"></script>
|
||||||
<script src="{{ url_for("static", filename="js/tagsinput.js") }}"></script>
|
<script src="{{ url_for("static", filename="js/tagsinput.js") }}"></script>
|
||||||
<script src="{{ url_for("static", filename="js/bootstrap.min.js") }}"></script>
|
<script src="{{ url_for("static", filename="js/bootstrap.min.js") }}"></script>
|
||||||
|
<script src="{{ url_for("static", filename="js/moment.min.js") }}"></script>
|
||||||
{%- if request.path == "/dashboard" %}
|
{%- if request.path == "/dashboard" %}
|
||||||
<link rel=stylesheet type=text/css href="{{ url_for("static", filename='css/datatables.min.css') }}">
|
<link rel=stylesheet type=text/css href="{{ url_for("static", filename='css/datatables.min.css') }}">
|
||||||
<script src="{{ url_for("static", filename="js/datatables.min.js") }}"></script>
|
<script src="{{ url_for("static", filename="js/datatables.min.js") }}"></script>
|
||||||
|
@ -45,6 +46,7 @@
|
||||||
<li class="nav-item{% if current_view == 'list_bills' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.list_bills") }}">{{ _("Bills") }}</a></li>
|
<li class="nav-item{% if current_view == 'list_bills' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.list_bills") }}">{{ _("Bills") }}</a></li>
|
||||||
<li class="nav-item{% if current_view == 'settle_bill' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.settle_bill") }}">{{ _("Settle") }}</a></li>
|
<li class="nav-item{% if current_view == 'settle_bill' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.settle_bill") }}">{{ _("Settle") }}</a></li>
|
||||||
<li class="nav-item{% if current_view == 'statistics' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.statistics") }}">{{ _("Statistics") }}</a></li>
|
<li class="nav-item{% if current_view == 'statistics' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.statistics") }}">{{ _("Statistics") }}</a></li>
|
||||||
|
<li class="nav-item{% if current_view == 'history' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.history") }}">{{ _("History") }}</a></li>
|
||||||
<li class="nav-item{% if current_view == 'edit_project' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.edit_project") }}">{{ _("Settings") }}</a></li>
|
<li class="nav-item{% if current_view == 'edit_project' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.edit_project") }}">{{ _("Settings") }}</a></li>
|
||||||
<li class="nav-item{% if current_view == 'upload_json' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.upload_json") }}">{{ _("Import") }}</a></li>
|
<li class="nav-item{% if current_view == 'upload_json' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.upload_json") }}">{{ _("Import") }}</a></li>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -31,6 +31,7 @@ from flask import (
|
||||||
from flask_babel import get_locale, gettext as _
|
from flask_babel import get_locale, gettext as _
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
from sqlalchemy_continuum import Operation
|
||||||
from werkzeug.exceptions import NotFound
|
from werkzeug.exceptions import NotFound
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
@ -46,7 +47,8 @@ from ihatemoney.forms import (
|
||||||
get_billform_for,
|
get_billform_for,
|
||||||
UploadForm,
|
UploadForm,
|
||||||
)
|
)
|
||||||
from ihatemoney.models import db, Project, Person, Bill
|
from ihatemoney.history import get_history_queries, get_history
|
||||||
|
from ihatemoney.models import db, Project, Person, Bill, LoggingMode
|
||||||
from ihatemoney.utils import (
|
from ihatemoney.utils import (
|
||||||
Redirect303,
|
Redirect303,
|
||||||
list_of_dicts2json,
|
list_of_dicts2json,
|
||||||
|
@ -741,6 +743,49 @@ def settle_bill():
|
||||||
return render_template("settle_bills.html", bills=bills, current_view="settle_bill")
|
return render_template("settle_bills.html", bills=bills, current_view="settle_bill")
|
||||||
|
|
||||||
|
|
||||||
|
@main.route("/<project_id>/history")
|
||||||
|
def history():
|
||||||
|
"""Query for the version entries associated with this project."""
|
||||||
|
history = get_history(g.project, human_readable_names=True)
|
||||||
|
|
||||||
|
any_ip_addresses = False
|
||||||
|
for event in history:
|
||||||
|
if event["ip"]:
|
||||||
|
any_ip_addresses = True
|
||||||
|
break
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"history.html",
|
||||||
|
current_view="history",
|
||||||
|
history=history,
|
||||||
|
any_ip_addresses=any_ip_addresses,
|
||||||
|
LoggingMode=LoggingMode,
|
||||||
|
OperationType=Operation,
|
||||||
|
current_log_pref=g.project.logging_preference,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main.route("/<project_id>/erase_history", methods=["POST"])
|
||||||
|
def erase_history():
|
||||||
|
"""Erase all history entries associated with this project."""
|
||||||
|
for query in get_history_queries(g.project):
|
||||||
|
query.delete(synchronize_session="fetch")
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(url_for(".history"))
|
||||||
|
|
||||||
|
|
||||||
|
@main.route("/<project_id>/strip_ip_addresses", methods=["POST"])
|
||||||
|
def strip_ip_addresses():
|
||||||
|
"""Strip ip addresses from history entries associated with this project."""
|
||||||
|
for query in get_history_queries(g.project):
|
||||||
|
for version_object in query.all():
|
||||||
|
version_object.transaction.remote_addr = None
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return redirect(url_for(".history"))
|
||||||
|
|
||||||
|
|
||||||
@main.route("/<project_id>/statistics")
|
@main.route("/<project_id>/statistics")
|
||||||
def statistics():
|
def statistics():
|
||||||
"""Compute what each member has paid and spent and display it"""
|
"""Compute what each member has paid and spent and display it"""
|
||||||
|
|
Loading…
Reference in a new issue