diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 955f5af5..cf10922d 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -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 diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 224cfed6..8e8701a2 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -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" 0 %} - + + + + + + + + {% for (weights, bill) in bills.items %} @@ -147,6 +153,11 @@ {{ weighted_bill_amount(bill, weights) }} +
{{ _("When?") }} - {{ _("Who paid?") }} - {{ _("For what?") }} - {{ _("For whom?") }} - {{ _("How much?") }} - {{ _("Actions") }}
{{ _("When?") }}{{ _("Who paid?") }}{{ _("For what?") }}{{ _("For whom?") }}{{ _("How much?") }} + {{ _("Tags") }} + {{ _("Actions") }}
+ {% for tag in bill.tags %} + #{{ tag.name }} + {% endfor %} + {{ _('edit') }}
diff --git a/ihatemoney/templates/statistics.html b/ihatemoney/templates/statistics.html index 86f9cd42..c1f80e88 100644 --- a/ihatemoney/templates/statistics.html +++ b/ihatemoney/templates/statistics.html @@ -23,12 +23,28 @@

{{ _("Expenses by Month") }}

- + + + + + {% for tag in tags %} + + {% endfor %} + + {% for month in months %} + {% for tag in tags %} + {% if tag.name in tags_monthly_stats[month.year][month.month] %} + + {% else %} + + {% endif %} + {% endfor %} + {% endfor %} diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 0cec4864..749c19b8 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -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()
{{ _("Period") }}{{ _("Spent") }}
{{ _("Period") }}{{ _("Spent") }}#{{ tag.name }}
{{ month|dateformat("MMMM yyyy") }} {{ monthly_stats[month.year][month.month]|currency }}{{ tags_monthly_stats[month.year][month.month][tag.name]|currency }} -