mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-05 04:31:49 +02:00
commit
50ab022856
6 changed files with 255 additions and 7 deletions
|
@ -114,12 +114,13 @@ class Project(db.Model):
|
||||||
balances, should_pay, should_receive = (defaultdict(int) for time in (1, 2, 3))
|
balances, should_pay, should_receive = (defaultdict(int) for time in (1, 2, 3))
|
||||||
|
|
||||||
for bill in self.get_bills_unordered().all():
|
for bill in self.get_bills_unordered().all():
|
||||||
should_receive[bill.payer.id] += bill.converted_amount
|
if not bill.archive:
|
||||||
total_weight = sum(ower.weight for ower in bill.owers)
|
should_receive[bill.payer.id] += bill.converted_amount
|
||||||
for ower in bill.owers:
|
total_weight = sum(ower.weight for ower in bill.owers)
|
||||||
should_pay[ower.id] += (
|
for ower in bill.owers:
|
||||||
ower.weight * bill.converted_amount / total_weight
|
should_pay[ower.id] += (
|
||||||
)
|
ower.weight * bill.converted_amount / total_weight
|
||||||
|
)
|
||||||
|
|
||||||
for person in self.members:
|
for person in self.members:
|
||||||
balance = should_receive[person.id] - should_pay[person.id]
|
balance = should_receive[person.id] - should_pay[person.id]
|
||||||
|
@ -678,7 +679,8 @@ class Bill(db.Model):
|
||||||
original_currency = db.Column(db.String(3))
|
original_currency = db.Column(db.String(3))
|
||||||
converted_amount = db.Column(db.Float)
|
converted_amount = db.Column(db.Float)
|
||||||
|
|
||||||
archive = db.Column(db.Integer, db.ForeignKey("archive.id"))
|
# archive = db.Column(db.Integer, db.ForeignKey("archive.id"))
|
||||||
|
archive = db.Column(db.Boolean)
|
||||||
|
|
||||||
currency_helper = CurrencyConverter()
|
currency_helper = CurrencyConverter()
|
||||||
|
|
||||||
|
@ -692,6 +694,7 @@ class Bill(db.Model):
|
||||||
payer_id: int = None,
|
payer_id: int = None,
|
||||||
project_default_currency: str = "",
|
project_default_currency: str = "",
|
||||||
what: str = "",
|
what: str = "",
|
||||||
|
archive: bool = False
|
||||||
):
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.amount = amount
|
self.amount = amount
|
||||||
|
@ -704,6 +707,7 @@ class Bill(db.Model):
|
||||||
self.converted_amount = self.currency_helper.exchange_currency(
|
self.converted_amount = self.currency_helper.exchange_currency(
|
||||||
self.amount, self.original_currency, project_default_currency
|
self.amount, self.original_currency, project_default_currency
|
||||||
)
|
)
|
||||||
|
self.archive = archive
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _to_serialize(self):
|
def _to_serialize(self):
|
||||||
|
@ -750,6 +754,9 @@ class Bill(db.Model):
|
||||||
f"{', '.join([o.name for o in self.owers])}>"
|
f"{', '.join([o.name for o in self.owers])}>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def set_archived(self):
|
||||||
|
self.archive = True
|
||||||
|
|
||||||
|
|
||||||
class Archive(db.Model):
|
class Archive(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
|
@ -295,6 +295,7 @@ footer .footer-left {
|
||||||
|
|
||||||
.bill-actions > form > .delete,
|
.bill-actions > form > .delete,
|
||||||
.bill-actions > .edit,
|
.bill-actions > .edit,
|
||||||
|
.bill-actions > form > .archive,
|
||||||
.bill-actions > .show {
|
.bill-actions > .show {
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -314,6 +315,10 @@ footer .footer-left {
|
||||||
background: url("../images/edit.png") no-repeat right;
|
background: url("../images/edit.png") no-repeat right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bill-actions > form > .archive {
|
||||||
|
background: url("../images/archive.png") no-repeat right;
|
||||||
|
}
|
||||||
|
|
||||||
.bill-actions > .show {
|
.bill-actions > .show {
|
||||||
background: url("../images/show.png") no-repeat right;
|
background: url("../images/show.png") no-repeat right;
|
||||||
}
|
}
|
||||||
|
|
BIN
ihatemoney/static/images/archive.png
Normal file
BIN
ihatemoney/static/images/archive.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 242 B |
|
@ -94,6 +94,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span id="new-bill" class="ml-auto pb-2" {% if not g.project.members %} data-toggle="tooltip" title="{{_('You should start by adding participants')}}" {% endif %}>
|
<span id="new-bill" class="ml-auto pb-2" {% if not g.project.members %} data-toggle="tooltip" title="{{_('You should start by adding participants')}}" {% endif %}>
|
||||||
|
<a href="{{ url_for('.list_bills_archived')}}"><button class="btn btn-primary" data-toggle="modal" data-keyboard="false">Go to Archive</button></a>
|
||||||
<a href="{{ url_for('.add_bill') }}" class="btn btn-primary {% if not g.project.members %} disabled {% endif %}" data-toggle="modal" data-keyboard="false" data-target="#bill-form">
|
<a href="{{ url_for('.add_bill') }}" class="btn btn-primary {% if not g.project.members %} disabled {% endif %}" data-toggle="modal" data-keyboard="false" data-target="#bill-form">
|
||||||
<i class="icon icon-white before-text">{{ static_include("images/plus.svg") | safe }}</i>
|
<i class="icon icon-white before-text">{{ static_include("images/plus.svg") | safe }}</i>
|
||||||
{{ _("Add a new bill") }}
|
{{ _("Add a new bill") }}
|
||||||
|
@ -102,6 +103,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if bills.total > 0 %}
|
{% if bills.total > 0 %}
|
||||||
|
<h3>Active Bills</h3>
|
||||||
<table id="bill_table" class="col table table-striped table-hover table-responsive-sm">
|
<table id="bill_table" class="col table table-striped table-hover table-responsive-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>{{ _("When?") }}
|
<tr><th>{{ _("When?") }}
|
||||||
|
@ -113,6 +115,7 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for (weights, bill) in bills.items %}
|
{% for (weights, bill) in bills.items %}
|
||||||
|
{% if bill.archive == false %}
|
||||||
<tr owers="{{bill.owers|join(',','id')}}" payer="{{bill.payer.id}}">
|
<tr owers="{{bill.owers|join(',','id')}}" payer="{{bill.payer.id}}">
|
||||||
<td>
|
<td>
|
||||||
<span data-toggle="tooltip" data-placement="top"
|
<span data-toggle="tooltip" data-placement="top"
|
||||||
|
@ -141,11 +144,16 @@
|
||||||
{{ csrf_form.csrf_token }}
|
{{ csrf_form.csrf_token }}
|
||||||
<button class="action delete" type="submit" title="{{ _("delete") }}"></button>
|
<button class="action delete" type="submit" title="{{ _("delete") }}"></button>
|
||||||
</form>
|
</form>
|
||||||
|
<form action="{{ url_for(".archive_bill", bill_id=bill.id) }}" method="POST">
|
||||||
|
{{ csrf_form.csrf_token }}
|
||||||
|
<button class="archive" type="submit" title="{{ _("archive") }}"></button>
|
||||||
|
</form>
|
||||||
{% if bill.external_link %}
|
{% if bill.external_link %}
|
||||||
<a class="show" href="{{ bill.external_link }}" ref="noopener" target="_blank" title="{{ _("show") }}">{{ _('show') }} </a>
|
<a class="show" href="{{ bill.external_link }}" ref="noopener" target="_blank" title="{{ _("show") }}">{{ _('show') }} </a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
180
ihatemoney/templates/list_bills_archived.html
Normal file
180
ihatemoney/templates/list_bills_archived.html
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
{% extends "sidebar_table_layout.html" %}
|
||||||
|
|
||||||
|
{%- macro weighted_bill_amount(bill, weights, currency=bill.original_currency, amount=bill.amount) %}
|
||||||
|
{{ amount|currency(currency) }}
|
||||||
|
{%- if weights != 1.0 %}
|
||||||
|
({{ _("%(amount)s each", amount=(amount / weights)|currency(currency)) }})
|
||||||
|
{%- endif -%}
|
||||||
|
{% endmacro -%}
|
||||||
|
|
||||||
|
{% block title %} - {{ g.project.name }}{% endblock %}
|
||||||
|
{% block js %}
|
||||||
|
{% if add_bill %} $('#new-bill > a').click(); {% endif %}
|
||||||
|
|
||||||
|
// ask for confirmation before removing an user
|
||||||
|
$('.action.delete').each(function(){
|
||||||
|
var link = $(this).find('button');
|
||||||
|
link.click(function(){
|
||||||
|
if ($(this).hasClass("confirm")){
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$(this).html("{{_("you sure?")}}");
|
||||||
|
$(this).addClass("confirm");
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var highlight_owers = function(){
|
||||||
|
var ower_ids = $(this).attr("owers").split(',');
|
||||||
|
var payer_id = $(this).attr("payer");
|
||||||
|
$.each(ower_ids, function(i, val){
|
||||||
|
$('#bal-member-'+val).addClass("ower_line");
|
||||||
|
});
|
||||||
|
$("#bal-member-"+payer_id).addClass("payer_line");
|
||||||
|
};
|
||||||
|
|
||||||
|
var unhighlight_owers = function(){
|
||||||
|
$('[id^="bal-member-"]').removeClass("ower_line payer_line");
|
||||||
|
};
|
||||||
|
|
||||||
|
$('#bill_table tbody tr').hover(highlight_owers, unhighlight_owers);
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar %}
|
||||||
|
<div class="sidebar_content">
|
||||||
|
<form id="add-member-form" action="{{ url_for(".add_member") }}" method="post" class="py-3">
|
||||||
|
{{ forms.add_member(member_form) }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="table_overflow">
|
||||||
|
{{ balance_table(member_edit=True) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="identifier">
|
||||||
|
{% if g.lang == 'fr' %}
|
||||||
|
<a class="btn btn-secondary btn-block" href="" onclick="javascript:showGallery(); return false;">
|
||||||
|
<i class="icon icon-white high before-text">{{ static_include("images/read.svg") | safe }}</i>
|
||||||
|
Voir la BD explicative
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a class="btn btn-secondary btn-block" href="{{ url_for('.invite') }}">
|
||||||
|
<i class="icon icon-white high before-text">{{ static_include("images/paper-plane.svg") | safe }}</i>
|
||||||
|
{{ _("Invite people") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div id="bill-form" class="modal fade show" role="dialog">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">{{ _('Add a bill') }}</h3>
|
||||||
|
<a href="#" class="close" data-dismiss="modal">×</a>
|
||||||
|
</div>
|
||||||
|
<form action="{{ url_for(".add_bill") }}" method="post" class="modal-body container">
|
||||||
|
{{ forms.add_bill(bill_form, title=False) }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap w-100 pt-2 mt-2" id="bill-toolbar">
|
||||||
|
{% if bills.pages > 1 %}
|
||||||
|
<ul class="pagination mr-2 mb-0 pb-2 flex-wrap" id="pagination-top">
|
||||||
|
<li class="page-item {% if bills.page == 1 %}disabled{% endif %}"><a class="page-link" href="{{ url_for('main.list_bills', page=bills.prev_num) }}">« {{ _("Newer bills") }}</a></li>
|
||||||
|
{%- for page in bills.iter_pages() %}
|
||||||
|
{% if page %}
|
||||||
|
<li class="page-item {% if page == bills.page %}active{% endif %}"><a class="page-link" href="{{ url_for('main.list_bills', page=page) }}">{{ page }}</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="ellipsis page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
<li class="page-item {% if bills.page == bills.pages %}disabled{% endif %}"><a class="page-link" href="{{ url_for('main.list_bills', page=bills.next_num) }}">{{ _("Older bills") }} »</a></li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
<span id="new-bill" class="ml-auto pb-2" {% if not g.project.members %} data-toggle="tooltip" title="{{_('You should start by adding participants')}}" {% endif %}>
|
||||||
|
<a href="{{ url_for('.list_bills')}}"><button class="btn btn-primary" data-toggle="modal" data-keyboard="false">Go to Active Bills</button></a>
|
||||||
|
<a href="{{ url_for('.add_bill') }}" class="btn btn-primary {% if not g.project.members %} disabled {% endif %}" data-toggle="modal" data-keyboard="false" data-target="#bill-form">
|
||||||
|
<i class="icon icon-white before-text">{{ static_include("images/plus.svg") | safe }}</i>
|
||||||
|
{{ _("Add a new bill") }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if bills.total > 0 %}
|
||||||
|
<h3>Archived Bills</h3>
|
||||||
|
<table id="bill_table" class="col table table-striped table-hover table-responsive-sm">
|
||||||
|
<thead>
|
||||||
|
<tr><th>{{ _("When?") }}
|
||||||
|
</th><th>{{ _("Who paid?") }}
|
||||||
|
</th><th>{{ _("For what?") }}
|
||||||
|
</th><th>{{ _("For whom?") }}
|
||||||
|
</th><th>{{ _("How much?") }}
|
||||||
|
</th><th>{{ _("Actions") }}</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for (weights, bill) in bills.items %}
|
||||||
|
{% if bill.archive == true %}
|
||||||
|
<tr owers="{{bill.owers|join(',','id')}}" payer="{{bill.payer.id}}">
|
||||||
|
<td>
|
||||||
|
<span data-toggle="tooltip" data-placement="top"
|
||||||
|
title="{{ _('Added on %(date)s', date=bill.creation_date|dateformat("long") if bill.creation_date else bill.date|dateformat("long")) }}">
|
||||||
|
{{ bill.date }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ bill.payer }}</td>
|
||||||
|
<td>{{ bill.what }}</td>
|
||||||
|
<td>{% if bill.owers|length == g.project.members|length -%}
|
||||||
|
{{ _("Everyone") }}
|
||||||
|
{%- elif bill.owers|length > g.project.members|length / 2 + 1 -%}
|
||||||
|
{{ _("Everyone but %(excluded)s", excluded=g.project.members|reject('in', bill.owers)|join(', ', 'name')) }}
|
||||||
|
{%- else -%}
|
||||||
|
{{ bill.owers|join(', ', 'name') }}
|
||||||
|
{%- endif %}</td>
|
||||||
|
<td>
|
||||||
|
<span data-toggle="tooltip" data-placement="top"
|
||||||
|
title="{{ weighted_bill_amount(bill, weights, g.project.default_currency, bill.converted_amount) if bill.original_currency != g.project.default_currency else '' }}">
|
||||||
|
{{ weighted_bill_amount(bill, weights) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="bill-actions">
|
||||||
|
<a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a>
|
||||||
|
<form action="{{ url_for(".delete_bill", bill_id=bill.id) }}" method="POST">
|
||||||
|
{{ csrf_form.csrf_token }}
|
||||||
|
<button class="action delete" type="submit" title="{{ _("delete") }}"></button>
|
||||||
|
</form>
|
||||||
|
<form action="{{ url_for(".archive_bill", bill_id=bill.id) }}" method="POST">
|
||||||
|
{{ csrf_form.csrf_token }}
|
||||||
|
<button class="archive" type="submit" title="{{ _("unarchive") }}"></button>
|
||||||
|
</form>
|
||||||
|
{% if bill.external_link %}
|
||||||
|
<a class="show" href="{{ bill.external_link }}" ref="noopener" target="_blank" title="{{ _("show") }}">{{ _('show') }} </a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% 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 billimg">{{ static_include("images/bill.svg") | safe }}</i>
|
||||||
|
<h3>{{ _('No bills')}}</h3>
|
||||||
|
<p>
|
||||||
|
{{ _("Nothing to list yet.")}}<br />
|
||||||
|
{{ _("You probably want to") }}
|
||||||
|
{%- if g.project.members %} <a href="{{ url_for('.add_bill') }}" data-toggle="modal" data-target="#bill-form">
|
||||||
|
{{- _("add a bill") -}}
|
||||||
|
</a> ?
|
||||||
|
{% else %} <a href="{{ url_for('.add_member') }}">
|
||||||
|
{{- _('add participants') -}}
|
||||||
|
</a> ?
|
||||||
|
{%- endif -%}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
|
@ -645,6 +645,31 @@ def list_bills():
|
||||||
current_view="list_bills",
|
current_view="list_bills",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@main.route("/<project_id>/archived")
|
||||||
|
def list_bills_archived():
|
||||||
|
bill_form = get_billform_for(g.project)
|
||||||
|
# Used for CSRF validation
|
||||||
|
csrf_form = EmptyForm()
|
||||||
|
# set the last selected payer as default choice if exists
|
||||||
|
if "last_selected_payer" in session:
|
||||||
|
bill_form.payer.data = session["last_selected_payer"]
|
||||||
|
|
||||||
|
# Each item will be a (weight_sum, Bill) tuple.
|
||||||
|
# TODO: improve this awkward result using column_property:
|
||||||
|
# https://docs.sqlalchemy.org/en/14/orm/mapped_sql_expr.html.
|
||||||
|
weighted_bills = g.project.get_bill_weights_ordered().paginate(
|
||||||
|
per_page=100, error_out=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"list_bills_archived.html",
|
||||||
|
bills=weighted_bills,
|
||||||
|
member_form=MemberForm(g.project),
|
||||||
|
bill_form=bill_form,
|
||||||
|
csrf_form=csrf_form,
|
||||||
|
add_bill=request.values.get("add_bill", False),
|
||||||
|
current_view="list_bills",
|
||||||
|
)
|
||||||
|
|
||||||
@main.route("/<project_id>/members/add", methods=["GET", "POST"])
|
@main.route("/<project_id>/members/add", methods=["GET", "POST"])
|
||||||
def add_member():
|
def add_member():
|
||||||
|
@ -766,6 +791,29 @@ def delete_bill(bill_id):
|
||||||
|
|
||||||
return redirect(url_for(".list_bills"))
|
return redirect(url_for(".list_bills"))
|
||||||
|
|
||||||
|
@main.route("/<project_id>/archive/<int:bill_id>", methods=["POST"])
|
||||||
|
def archive_bill(bill_id):
|
||||||
|
form = EmptyForm()
|
||||||
|
if not form.validate():
|
||||||
|
flash(format_form_errors(form, _("Error archiving bill")), category="danger")
|
||||||
|
return redirect(url_for(".list_bills"))
|
||||||
|
|
||||||
|
bill = Bill.query.get(g.project, bill_id)
|
||||||
|
if not bill:
|
||||||
|
return redirect(url_for(".list_bills"))
|
||||||
|
|
||||||
|
if bill.archive == True:
|
||||||
|
bill.archive = False
|
||||||
|
else:
|
||||||
|
bill.archive = True
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if (bill.archive == True):
|
||||||
|
flash(_("The bill has been archived"))
|
||||||
|
return redirect(url_for(".list_bills_archived"))
|
||||||
|
else:
|
||||||
|
flash(_("The bill has been unarchived"))
|
||||||
|
return redirect(url_for(".list_bills"))
|
||||||
|
|
||||||
@main.route("/<project_id>/edit/<int:bill_id>", methods=["GET", "POST"])
|
@main.route("/<project_id>/edit/<int:bill_id>", methods=["GET", "POST"])
|
||||||
def edit_bill(bill_id):
|
def edit_bill(bill_id):
|
||||||
|
|
Loading…
Reference in a new issue