Compare commits

...

4 commits

Author SHA1 Message Date
488e4e2fc9
Merge f968c9870c into 19ecdb5052 2025-01-05 22:11:46 +01:00
zorun
19ecdb5052
Change settle endpoint to use POST instead of GET (#1303)
Some checks failed
CI / lint (push) Has been cancelled
CI / docs (push) Has been cancelled
Docker build / test (push) Has been cancelled
CI / test (mariadb, minimal, 3.11) (push) Has been cancelled
CI / test (mariadb, normal, 3.11) (push) Has been cancelled
CI / test (mariadb, normal, 3.9) (push) Has been cancelled
CI / test (postgresql, minimal, 3.11) (push) Has been cancelled
CI / test (postgresql, normal, 3.11) (push) Has been cancelled
CI / test (postgresql, normal, 3.9) (push) Has been cancelled
CI / test (sqlite, minimal, 3.10) (push) Has been cancelled
CI / test (sqlite, minimal, 3.11) (push) Has been cancelled
CI / test (sqlite, minimal, 3.12) (push) Has been cancelled
CI / test (sqlite, minimal, 3.9) (push) Has been cancelled
CI / test (sqlite, normal, 3.10) (push) Has been cancelled
CI / test (sqlite, normal, 3.11) (push) Has been cancelled
CI / test (sqlite, normal, 3.12) (push) Has been cancelled
CI / test (sqlite, normal, 3.9) (push) Has been cancelled
Docker build / build_upload (push) Has been cancelled
Co-authored-by: Baptiste Jonglez <git@bitsofnetworks.org>
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
2025-01-05 22:11:41 +01:00
f968c9870c
FIXUP: some more work 2024-09-29 16:16:41 +02:00
14cc9b96d3
feat(tags): Add tags on bills
Tags can now be added in the description of a bill, using a hashtag
symbol (`#tagname`).

There is no way to "manage" the tags, for simplicity, they are part of
the "what" field, and are parsed via a regular expression.

Statistics have been updated to include tags per month.

Under the hood, a new `tag` table has been added.
2024-05-30 23:12:50 +02:00
9 changed files with 486 additions and 95 deletions

View file

@ -1,6 +1,6 @@
from datetime import datetime
import decimal
from re import match
from datetime import datetime
from re import findall, match
from types import SimpleNamespace
import email_validator
@ -14,6 +14,8 @@ from wtforms.fields import (
BooleanField,
DateField,
DecimalField,
HiddenField,
IntegerField,
Label,
PasswordField,
SelectField,
@ -39,7 +41,7 @@ from wtforms.validators import (
)
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, Tag
from ihatemoney.utils import (
em_surround,
eval_arithmetic_expression,
@ -134,7 +136,8 @@ class EditProjectForm(FlaskForm):
_("New private code"),
description=_("Enter a new code if you want to change it"),
)
contact_email = StringField(_("Email"), validators=[DataRequired(), Email()])
contact_email = StringField(_("Email"), validators=[
DataRequired(), Email()])
project_history = BooleanField(_("Enable project history"))
ip_recording = BooleanField(_("Use IP tracking for project history"))
currency_helper = CurrencyConverter()
@ -227,7 +230,8 @@ class ImportProjectForm(FlaskForm):
"File",
validators=[
FileRequired(),
FileAllowed(["json", "JSON", "csv", "CSV"], "Incorrect file format"),
FileAllowed(["json", "JSON", "csv", "CSV"],
"Incorrect file format"),
],
description=_("Compatible with Cospend"),
)
@ -348,9 +352,11 @@ class ResetPasswordForm(FlaskForm):
class BillForm(FlaskForm):
date = DateField(_("When?"), validators=[DataRequired()], default=datetime.now)
date = DateField(_("When?"), validators=[
DataRequired()], default=datetime.now)
what = StringField(_("What?"), validators=[DataRequired()])
payer = SelectField(_("Who paid?"), validators=[DataRequired()], coerce=int)
payer = SelectField(_("Who paid?"), validators=[
DataRequired()], coerce=int)
amount = CalculatorStringField(_("How much?"), validators=[DataRequired()])
currency_helper = CurrencyConverter()
original_currency = SelectField(_("Currency"), validators=[DataRequired()])
@ -372,8 +378,30 @@ class BillForm(FlaskForm):
submit = SubmitField(_("Submit"))
submit2 = SubmitField(_("Submit and add a new one"))
def parse_hashtags(self, project, what):
"""Handles the hashtags which can be optionally specified in the 'what'
field, using the `grocery #hash #otherhash` syntax.
Returns: the new "what" field (with hashtags stripped-out) and the list
of tags.
"""
hashtags = findall(r"#(\w+)", what)
if not hashtags:
return what, []
for tag in hashtags:
what = what.replace(f"#{tag}", "")
return what, hashtags
def export(self, project):
return Bill(
"""This is triggered on bill creation.
"""
what, hashtags = self.parse_hashtags(project, self.what.data)
bill = Bill(
amount=float(self.amount.data),
date=self.date.data,
external_link=self.external_link.data,
@ -381,14 +409,17 @@ class BillForm(FlaskForm):
owers=Person.query.get_by_ids(self.payed_for.data, project),
payer_id=self.payer.data,
project_default_currency=project.default_currency,
what=self.what.data,
what=what,
bill_type=self.bill_type.data,
)
bill.set_tags(hashtags, project)
return bill
def save(self, bill, project):
what, hashtags = self.parse_hashtags(project, self.what.data)
bill.payer_id = self.payer.data
bill.amount = self.amount.data
bill.what = self.what.data
bill.what = what
bill.bill_type = BillType(self.bill_type.data)
bill.external_link = self.external_link.data
bill.date = self.date.data
@ -397,19 +428,22 @@ class BillForm(FlaskForm):
bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency
)
bill.set_tags(hashtags, project)
return bill
def fill(self, bill, project):
self.payer.data = bill.payer_id
self.amount.data = bill.amount
self.what.data = bill.what
hashtags = ' '.join([f'#{tag.name}' for tag in bill.tags])
self.what.data = bill.what.strip() + f' {hashtags}'
self.bill_type.data = bill.bill_type
self.external_link.data = bill.external_link
self.original_currency.data = bill.original_currency
self.date.data = bill.date
self.payed_for.data = [int(ower.id) for ower in bill.owers]
self.original_currency.label = Label("original_currency", _("Currency"))
self.original_currency.label = Label(
"original_currency", _("Currency"))
self.original_currency.description = _(
"Project default: %(currency)s",
currency=render_localized_currency(
@ -437,11 +471,30 @@ class BillForm(FlaskForm):
raise ValidationError(msg)
class MemberForm(FlaskForm):
name = StringField(_("Name"), validators=[DataRequired()], filters=[strip_filter])
class HiddenCommaDecimalField(HiddenField, CommaDecimalField):
pass
weight_validators = [NumberRange(min=0.1, message=_("Weights should be positive"))]
weight = CommaDecimalField(_("Weight"), default=1, validators=weight_validators)
class HiddenIntegerField(HiddenField, IntegerField):
pass
class SettlementForm(FlaskForm):
"""Used internally for validation, not directly visible to users"""
amount = HiddenCommaDecimalField("Amount", validators=[DataRequired()])
sender_id = HiddenIntegerField("Sender", validators=[DataRequired()])
receiver_id = HiddenIntegerField("Receiver", validators=[DataRequired()])
class MemberForm(FlaskForm):
name = StringField(_("Name"), validators=[
DataRequired()], filters=[strip_filter])
weight_validators = [NumberRange(
min=0.1, message=_("Weights should be positive"))]
weight = CommaDecimalField(
_("Weight"), default=1, validators=weight_validators)
submit = SubmitField(_("Add"))
def __init__(self, project, edit=False, *args, **kwargs):
@ -460,7 +513,8 @@ class MemberForm(FlaskForm):
Person.activated,
).all()
): # NOQA
raise ValidationError(_("This project already have this participant"))
raise ValidationError(
_("This project already have this participant"))
def save(self, project, person):
# if the user is already bound to the project, just reactivate him

View file

@ -0,0 +1,91 @@
"""Add a tags table
Revision ID: d53fe61e5521
Revises: 7a9b38559992
Create Date: 2024-05-16 00:32:19.566457
"""
# revision identifiers, used by Alembic.
revision = 'd53fe61e5521'
down_revision = '7a9b38559992'
from alembic import op
import sqlalchemy as sa
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('billtags_version',
sa.Column('bill_id', sa.Integer(), autoincrement=False, nullable=False),
sa.Column('tag_id', sa.Integer(), autoincrement=False, nullable=False),
sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False),
sa.Column('end_transaction_id', sa.BigInteger(), nullable=True),
sa.Column('operation_type', sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint('bill_id', 'tag_id', 'transaction_id')
)
with op.batch_alter_table('billtags_version', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_billtags_version_end_transaction_id'), ['end_transaction_id'], unique=False)
batch_op.create_index(batch_op.f('ix_billtags_version_operation_type'), ['operation_type'], unique=False)
batch_op.create_index(batch_op.f('ix_billtags_version_transaction_id'), ['transaction_id'], unique=False)
op.create_table('tag',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.String(length=64), nullable=True),
sa.Column('name', sa.UnicodeText(), nullable=True),
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ),
sa.PrimaryKeyConstraint('id'),
sqlite_autoincrement=True
)
op.create_table('billtags',
sa.Column('bill_id', sa.Integer(), nullable=False),
sa.Column('tag_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['bill_id'], ['bill.id'], ),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], ),
sa.PrimaryKeyConstraint('bill_id', 'tag_id'),
sqlite_autoincrement=True
)
with op.batch_alter_table('bill_version', schema=None) as batch_op:
batch_op.alter_column('bill_type',
existing_type=sa.TEXT(),
type_=sa.Enum('EXPENSE', 'REIMBURSEMENT', name='billtype'),
existing_nullable=True,
autoincrement=False)
with op.batch_alter_table('billowers', schema=None) as batch_op:
batch_op.alter_column('bill_id',
existing_type=sa.INTEGER(),
nullable=False)
batch_op.alter_column('person_id',
existing_type=sa.INTEGER(),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('billowers', schema=None) as batch_op:
batch_op.alter_column('person_id',
existing_type=sa.INTEGER(),
nullable=True)
batch_op.alter_column('bill_id',
existing_type=sa.INTEGER(),
nullable=True)
with op.batch_alter_table('bill_version', schema=None) as batch_op:
batch_op.alter_column('bill_type',
existing_type=sa.Enum('EXPENSE', 'REIMBURSEMENT', name='billtype'),
type_=sa.TEXT(),
existing_nullable=True,
autoincrement=False)
op.drop_table('billtags')
op.drop_table('tag')
with op.batch_alter_table('billtags_version', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_billtags_version_transaction_id'))
batch_op.drop_index(batch_op.f('ix_billtags_version_operation_type'))
batch_op.drop_index(batch_op.f('ix_billtags_version_end_transaction_id'))
op.drop_table('billtags_version')
# ### end Alembic commands ###

View file

@ -1,8 +1,9 @@
from collections import defaultdict
import datetime
from enum import Enum
import itertools
from collections import defaultdict
from enum import Enum
import sqlalchemy
from dateutil.parser import parse
from dateutil.relativedelta import relativedelta
from debts import settle
@ -14,7 +15,6 @@ from itsdangerous import (
URLSafeSerializer,
URLSafeTimedSerializer,
)
import sqlalchemy
from sqlalchemy import orm
from sqlalchemy.sql import func
from sqlalchemy_continuum import make_versioned, version_class
@ -125,7 +125,8 @@ class Project(db.Model):
balance spent paid
"""
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():
total_weight = sum(ower.weight for ower in bill.owers)
@ -181,11 +182,28 @@ class Project(db.Model):
:rtype dict:
"""
monthly = defaultdict(lambda: defaultdict(float))
for bill in self.get_bills_unordered().all():
if bill.bill_type == BillType.EXPENSE:
monthly[bill.date.year][bill.date.month] += bill.converted_amount
return monthly
@property
def tags_monthly_stats(self):
"""
:return: a dict of years mapping to a dict of months mapping to the amount
:rtype dict:
"""
tags_monthly = defaultdict(
lambda: defaultdict(lambda: defaultdict(float)))
for bill in self.get_bills_unordered().all():
if bill.bill_type == BillType.EXPENSE:
for tag in bill.tags:
tags_monthly[bill.date.year][bill.date.month][tag.name] += bill.converted_amount
return tags_monthly
@property
def uses_weights(self):
return len([i for i in self.members if i.weight != 1]) > 0
@ -322,7 +340,8 @@ class Project(db.Model):
year=newest_date.year, month=newest_date.month, day=1
)
# Infinite iterator towards the past
all_months = (newest_month - relativedelta(months=i) for i in itertools.count())
all_months = (newest_month - relativedelta(months=i)
for i in itertools.count())
# Stop when reaching one month before the first date
months = itertools.takewhile(
lambda x: x > oldest_date - relativedelta(months=1), all_months
@ -447,6 +466,10 @@ class Project(db.Model):
db.session.commit()
return person
def has_member(self, member_id):
person = Person.query.get(member_id, self)
return person is not None
def remove_project(self):
# We can't import at top level without circular dependencies
from ihatemoney.history import purge_history
@ -497,7 +520,8 @@ class Project(db.Model):
)
loads_kwargs["max_age"] = max_age
else:
project = Project.query.get(project_id) if project_id is not None else None
project = Project.query.get(
project_id) if project_id is not None else None
password = project.password if project is not None else ""
serializer = URLSafeSerializer(
current_app.config["SECRET_KEY"] + password, salt=token_type
@ -643,8 +667,50 @@ class Person(db.Model):
# We need to manually define a join table for m2m relations
billowers = db.Table(
"billowers",
db.Column("bill_id", db.Integer, db.ForeignKey("bill.id"), primary_key=True),
db.Column("person_id", db.Integer, db.ForeignKey("person.id"), primary_key=True),
db.Column("bill_id", db.Integer, db.ForeignKey(
"bill.id"), primary_key=True),
db.Column("person_id", db.Integer, db.ForeignKey(
"person.id"), primary_key=True),
sqlite_autoincrement=True,
)
class Tag(db.Model):
class TagQuery(BaseQuery):
def get_or_create(self, name, project):
exists = (
Tag.query.filter(Tag.name == name)
.filter(Tag.project_id == project.id)
.one_or_none()
)
if exists:
return exists
return Tag(name=name, project_id=project.id)
query_class = TagQuery
__versionned__ = {}
__table_args__ = {"sqlite_autoincrement": True}
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.String(64), db.ForeignKey("project.id"))
# bills = db.relationship("Bill", backref="tags")
name = db.Column(db.UnicodeText)
def __str__(self):
return self.name
def __repr__(self):
return self.name
# We need to manually define a join table for m2m relations
billtags = db.Table(
"billtags",
db.Column("bill_id", db.Integer, db.ForeignKey(
"bill.id"), primary_key=True),
db.Column("tag_id", db.Integer, db.ForeignKey("tag.id"), primary_key=True),
sqlite_autoincrement=True,
)
@ -688,6 +754,7 @@ class Bill(db.Model):
what = db.Column(db.UnicodeText)
bill_type = db.Column(db.Enum(BillType))
external_link = db.Column(db.UnicodeText)
tags = db.relationship(Tag, secondary=billtags)
original_currency = db.Column(db.String(3))
converted_amount = db.Column(db.Float)
@ -752,15 +819,24 @@ class Bill(db.Model):
else:
return 0
def __str__(self):
return self.what
def pay_each(self):
"""Warning: this is slow, if you need to compute this for many bills, do
it differently (see balance_full function)
"""
return self.pay_each_default(self.converted_amount)
def set_tags(self, tags, project):
object_tags = []
for tag_name in tags:
tag = Tag.query.get_or_create(name=tag_name, project=project)
db.session.add(tag)
object_tags.append(tag)
self.tags = object_tags
db.session.commit()
def __str__(self):
return self.what
def __repr__(self):
return (
f"<Bill of {self.amount} from {self.payer} for "
@ -790,3 +866,4 @@ sqlalchemy.orm.configure_mappers()
PersonVersion = version_class(Person)
ProjectVersion = version_class(Project)
BillVersion = version_class(Bill)
# TagVersion = version_class(Tag)

View file

@ -116,12 +116,18 @@
{% if bills.total > 0 %}
<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>
<tr><th>{{ _("When?") }}</th>
<th>{{ _("Who paid?") }}</th>
<th>{{ _("For what?") }}</th>
<th>{{ _("For whom?") }}</th>
<th>{{ _("How much?") }}</th>
<th data-toggle="tooltip"
data-placement="top"
title="{{ _('You can add tags to your bills by appending a #hashtag') }}">
{{ _("Tags") }}
</th>
<th>{{ _("Actions") }}</th>
</tr>
</thead>
<tbody>
{% for (weights, bill) in bills.items %}
@ -147,6 +153,11 @@
{{ weighted_bill_amount(bill, weights) }}
</span>
</td>
<td>
{% for tag in bill.tags %}
#{{ tag.name }}
{% endfor %}
</td>
<td class="bill-actions d-flex align-items-center">
<a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a>
<form class="delete-bill" action="{{ url_for(".delete_bill", bill_id=bill.id) }}" method="POST">

View file

@ -1,25 +1,41 @@
{% extends "sidebar_table_layout.html" %}
{% block sidebar %}
<div id="table_overflow">
{{ balance_table(show_weight=False) }}
</div>
{% endblock %}
{% block content %}
{% extends "sidebar_table_layout.html" %} {% block sidebar %}
<div id="table_overflow">{{ balance_table(show_weight=False) }}</div>
{% endblock %} {% block content %}
<table id="bill_table" class="split_bills table table-striped">
<thead><tr><th>{{ _("Who pays?") }}</th><th>{{ _("To whom?") }}</th><th>{{ _("How much?") }}</th><th>{{ _("Settled?") }}</th></tr></thead>
<thead>
<tr>
<th>{{ _("Who pays?") }}</th>
<th>{{ _("To whom?") }}</th>
<th>{{ _("How much?") }}</th>
<th>{{ _("Settled?") }}</th>
</tr>
</thead>
<tbody>
{% for bill in bills %}
<tr receiver={{bill.receiver.id}}>
<td>{{ bill.ower }}</td>
<td>{{ bill.receiver }}</td>
<td>{{ bill.amount|currency }}</td>
{% for transaction in transactions %}
<tr receiver="{{transaction.receiver.id}}">
<td>{{ transaction.ower }}</td>
<td>{{ transaction.receiver }}</td>
<td>{{ transaction.amount|currency }}</td>
<td>
<span id="settle-bill" class="ml-auto pb-2">
<a href="{{ url_for('.settle', amount = bill.amount, ower_id = bill.ower.id, payer_id = bill.receiver.id) }}" class="btn btn-primary">
<div data-toggle="tooltip" title='{{ _("Click here to record that the money transfer has been done") }}'>
<form class="" action="{{ url_for(".add_settlement_bill") }}" method="POST">
{{ settlement_form.csrf_token }}
{{ settlement_form.amount(value=transaction.amount) }}
{{ settlement_form.sender_id(value=transaction.ower.id) }}
{{ settlement_form.receiver_id(value=transaction.receiver.id) }}
<button class="btn btn-primary" type="submit" title="{{ _("Settle") }}">
<div
data-toggle="tooltip"
title='{{ _("Click here to record that the money transfer has been done") }}'
>
{{ _("Settle") }}
</div>
</button>
</form>
<a
href="{{ url_for('.add_settlement_bill', amount = transaction.amount, sender_id = transaction.ower.id, receiver_id = transaction.receiver.id) }}"
class="btn btn-primary"
>
{{ ("Settle") }}
</div>
</a>

View file

@ -23,12 +23,28 @@
</table>
<h2>{{ _("Expenses by Month") }}</h2>
<table id="monthly_stats" class="table table-striped">
<thead><tr><th>{{ _("Period") }}</th><th>{{ _("Spent") }}</th></tr></thead>
<thead>
<tr>
<th>{{ _("Period") }}</th>
<th>{{ _("Spent") }}</th>
{% for tag in tags %}
<th>#{{ tag.name }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for month in months %}
<tr>
<td>{{ month|dateformat("MMMM yyyy") }}</td>
<td>{{ monthly_stats[month.year][month.month]|currency }}</td>
{% for tag in tags %}
{% if tag.name in tags_monthly_stats[month.year][month.month] %}
<td>{{ tags_monthly_stats[month.year][month.month][tag.name]|currency }}</td>
{% else %}
<td> - </td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>

View file

@ -1358,23 +1358,25 @@ class TestBudget(IhatemoneyTestCase):
count = 0
for t in transactions:
count += 1
self.client.get(
"/raclette/settle"
+ "/"
+ str(t["amount"])
+ "/"
+ str(t["ower"].id)
+ "/"
+ str(t["receiver"].id)
self.client.post(
"/raclette/settle",
data={
"amount": t["amount"],
"sender_id": t["ower"].id,
"receiver_id": t["receiver"].id,
},
)
temp_transactions = project.get_transactions_to_settle_bill()
# test if the one has disappeared
assert len(temp_transactions) == len(transactions) - count
# test if theres a new one with bill_type reimbursement
# test if there is a new one with bill_type reimbursement
bill = project.get_newest_bill()
assert bill.bill_type == models.BillType.REIMBURSEMENT
return
# There should be no more settlement to do at the end
transactions = project.get_transactions_to_settle_bill()
assert len(transactions) == 0
def test_settle_zero(self):
self.post_project("raclette")
@ -1463,6 +1465,78 @@ class TestBudget(IhatemoneyTestCase):
# Create and log in as another project
self.post_project("tartiflette")
# Add a participant in this second project
self.client.post("/tartiflette/members/add", data={"name": "pirate"})
pirate = models.Person.query.filter(models.Person.id == 5).one()
assert pirate.name == "pirate"
# Try to add a new bill to another project
resp = self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "fromage frelaté",
"payer": 2,
"payed_for": [2, 3, 4],
"bill_type": "Expense",
"amount": "100.0",
},
)
# Ensure it has not been created
raclette = self.get_project("raclette")
assert raclette.get_bills().count() == 1
# Try to add a new bill in our project that references members of another project.
# First with invalid payed_for IDs.
resp = self.client.post(
"/tartiflette/add",
data={
"date": "2017-01-01",
"what": "soupe",
"payer": 5,
"payed_for": [3],
"bill_type": "Expense",
"amount": "5000.0",
},
)
# Ensure it has not been created
piratebill = models.Bill.query.filter(models.Bill.what == "soupe").one_or_none()
assert piratebill is None, "piratebill 1 should not exist"
# Then with invalid payer ID
self.client.post(
"/tartiflette/add",
data={
"date": "2017-02-01",
"what": "pain",
"payer": 3,
"payed_for": [5],
"bill_type": "Expense",
"amount": "5000.0",
},
)
# Ensure it has not been created
piratebill = models.Bill.query.filter(models.Bill.what == "pain").one_or_none()
assert piratebill is None, "piratebill 2 should not exist"
# Make sure we can actually create valid bills
self.client.post(
"/tartiflette/add",
data={
"date": "2017-03-01",
"what": "baguette",
"payer": 5,
"payed_for": [5],
"bill_type": "Expense",
"amount": "5.0",
},
)
# Ensure it has been created
okbill = models.Bill.query.filter(models.Bill.what == "baguette").one_or_none()
assert okbill is not None, "Bill baguette should exist"
assert okbill.what == "baguette"
# Now try to access and modify existing bills
modified_bill = {
"date": "2018-12-31",
"what": "roblochon",
@ -1556,6 +1630,24 @@ class TestBudget(IhatemoneyTestCase):
member = models.Person.query.filter(models.Person.id == 1).one_or_none()
assert member is None
# test new settle endpoint to add bills with wrong ids
self.client.post("/exit")
self.client.post(
"/authenticate", data={"id": "tartiflette", "password": "tartiflette"}
)
self.client.post(
"/tartiflette/settle",
data={
"sender_id": 4,
"receiver_id": 5,
"amount": "42.0",
},
)
piratebill = models.Bill.query.filter(
models.Bill.bill_type == models.BillType.REIMBURSEMENT
).one_or_none()
assert piratebill is None, "piratebill 3 should not exist"
@pytest.mark.skip(reason="Currency conversion is broken")
def test_currency_switch(self):
# A project should be editable

View file

@ -452,7 +452,9 @@ def format_form_errors(form, prefix):
)
else:
error_list = "</li><li>".join(
str(error) for (field, errors) in form.errors.items() for error in errors
f"<strong>{field}</strong> {error}"
for (field, errors) in form.errors.items()
for error in errors
)
errors = f"<ul><li>{error_list}</li></ul>"
# I18N: Form error with a list of errors

View file

@ -2,7 +2,7 @@
The blueprint for the web interface.
Contains all the interaction logic with the end user (except forms which
are directly handled in the forms module.
are directly handled in the forms module).
Basically, this blueprint takes care of the authentication and provides
some shortcuts to make your life better when coding (see `pull_project`
@ -10,12 +10,14 @@ and `add_project_id` for a quick overview)
"""
import datetime
from functools import wraps
import hashlib
import json
import os
from functools import wraps
from urllib.parse import urlparse, urlunparse
import qrcode
import qrcode.image.svg
from flask import (
Blueprint,
Response,
@ -34,8 +36,6 @@ from flask import (
)
from flask_babel import gettext as _
from flask_mail import Message
import qrcode
import qrcode.image.svg
from sqlalchemy_continuum import Operation
from werkzeug.exceptions import NotFound
from werkzeug.security import check_password_hash
@ -56,10 +56,11 @@ from ihatemoney.forms import (
ProjectForm,
ProjectFormWithCaptcha,
ResetPasswordForm,
SettlementForm,
get_billform_for,
)
from ihatemoney.history import get_history, get_history_queries, purge_history
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, db
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project, Tag, db
from ihatemoney.utils import (
Redirect303,
csv2list_of_dicts,
@ -160,7 +161,8 @@ def pull_project(endpoint, values):
project_id = entered_project_id.lower()
project = Project.query.get(project_id)
if not project:
raise Redirect303(url_for(".create_project", project_id=project_id))
raise Redirect303(
url_for(".create_project", project_id=project_id))
is_admin = session.get("is_admin")
is_invitation = endpoint == "main.join_project"
@ -374,7 +376,8 @@ def remind_password():
# send a link to reset the password
remind_message = Message(
"password recovery",
body=render_localized_template("password_reminder", project=project),
body=render_localized_template(
"password_reminder", project=project),
recipients=[project.contact_email],
)
success = send_email(remind_message)
@ -617,7 +620,8 @@ def invite():
msg = Message(
message_title,
body=message_body,
recipients=[email.strip() for email in form.emails.data.split(",")],
recipients=[email.strip()
for email in form.emails.data.split(",")],
)
success = send_email(msg)
if success:
@ -638,7 +642,8 @@ def invite():
token=g.project.generate_token(),
_external=True,
)
invite_link = urlunparse(urlparse(invite_link)._replace(scheme="ihatemoney"))
invite_link = urlunparse(
urlparse(invite_link)._replace(scheme="ihatemoney"))
qr = qrcode.QRCode(image_factory=qrcode.image.svg.SvgPathImage)
qr.add_data(invite_link)
qr.make(fit=True)
@ -852,24 +857,46 @@ def change_lang(lang):
@main.route("/<project_id>/settle_bills")
def settle_bill():
"""Compute the sum each one have to pay to each other and display it"""
bills = g.project.get_transactions_to_settle_bill()
return render_template("settle_bills.html", bills=bills, current_view="settle_bill")
transactions = g.project.get_transactions_to_settle_bill()
settlement_form = SettlementForm()
return render_template(
"settle_bills.html",
transactions=transactions,
settlement_form=settlement_form,
current_view="settle_bill",
)
@main.route("/<project_id>/settle/<amount>/<int:ower_id>/<int:payer_id>")
def settle(amount, ower_id, payer_id):
new_reinbursement = Bill(
amount=float(amount),
@main.route("/<project_id>/settle", methods=["POST"])
def add_settlement_bill():
"""Create a bill to register a settlement"""
form = SettlementForm(id=g.project.id)
if not form.validate():
flash(
format_form_errors(form, _("Error creating settlement bill")),
category="danger",
)
return redirect(url_for(".settle_bill"))
# Ensure that the sender and receiver ID are valid and part of this project
receiver_id = form.receiver_id.data
sender_id = form.sender_id.data
if not g.project.has_member(sender_id):
return redirect(url_for(".settle_bill"))
settlement = Bill(
amount=form.amount.data,
date=datetime.datetime.today(),
owers=[Person.query.get(payer_id)],
payer_id=ower_id,
owers=[Person.query.get(receiver_id, g.project)],
payer_id=sender_id,
project_default_currency=g.project.default_currency,
bill_type=BillType.REIMBURSEMENT,
what=_("Settlement"),
)
session.update()
db.session.add(new_reinbursement)
db.session.add(settlement)
db.session.commit()
flash(_("Settlement bill has been successfully added"), category="success")
@ -920,7 +947,8 @@ def strip_ip_addresses():
form = DestructiveActionProjectForm(id=g.project.id)
if not form.validate():
flash(
format_form_errors(form, _("Error deleting recorded IP addresses")),
format_form_errors(
form, _("Error deleting recorded IP addresses")),
category="danger",
)
return redirect(url_for(".history"))
@ -939,18 +967,22 @@ def statistics():
"""Compute what each participant has paid and spent and display it"""
# Determine range of months between which there are bills
months = g.project.active_months_range()
tags = Tag.query.filter(Tag.project_id == g.project.id)
return render_template(
"statistics.html",
members_stats=g.project.members_stats,
monthly_stats=g.project.monthly_stats,
tags_monthly_stats=g.project.tags_monthly_stats,
months=months,
tags=tags,
current_view="statistics",
)
def build_etag(project_id, last_modified):
return hashlib.md5(
(current_app.config["SECRET_KEY"] + project_id + last_modified).encode()
(current_app.config["SECRET_KEY"] +
project_id + last_modified).encode()
).hexdigest()