mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +02:00
FIXUP: some more work
This commit is contained in:
parent
14cc9b96d3
commit
f968c9870c
5 changed files with 156 additions and 42 deletions
|
@ -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
|
||||
|
|
|
@ -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 "
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue