FIXUP: some more work

This commit is contained in:
Alexis Métaireau 2024-09-29 16:16:41 +02:00
parent 14cc9b96d3
commit f968c9870c
No known key found for this signature in database
GPG key ID: 1C21B876828E5FF2
5 changed files with 156 additions and 42 deletions

View file

@ -135,7 +135,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()
@ -228,7 +229,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"),
)
@ -349,9 +351,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()])
@ -373,8 +377,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,
@ -382,20 +408,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
# Get the list of tags from the 'what' field
hashtags = findall(r"#(\w+)", self.what.data)
if hashtags:
bill.tags = [Tag(name=tag) for tag in hashtags]
for tag in hashtags:
self.what.data = self.what.data.replace(f"#{tag}", "")
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
@ -404,19 +427,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(
@ -445,10 +471,13 @@ class BillForm(FlaskForm):
class MemberForm(FlaskForm):
name = StringField(_("Name"), validators=[DataRequired()], filters=[strip_filter])
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)
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):
@ -467,7 +496,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

@ -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
@ -497,7 +516,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,13 +663,28 @@ 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}
@ -662,11 +697,15 @@ class Tag(db.Model):
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("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,
)
@ -776,15 +815,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 "

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

@ -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

@ -58,7 +58,7 @@ from ihatemoney.forms import (
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,
@ -154,7 +154,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"
@ -368,7 +369,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)
@ -611,7 +613,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:
@ -632,7 +635,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)
@ -914,7 +918,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"))
@ -933,18 +938,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()