aboutsummaryrefslogtreecommitdiff
path: root/ihatemoney
diff options
context:
space:
mode:
authorAndrew Dickinson <Andrew-Dickinson@users.noreply.github.com>2020-04-20 09:30:27 -0400
committerGitHub <noreply@github.com>2020-04-20 15:30:27 +0200
commit026a0722357d74b143ed2d974ad2d871a56041b3 (patch)
tree2f23323f01e5ec1dec07ef1032acc407cba38879 /ihatemoney
parent91ef80ebb712b06b6c48336beeb7f60219b0f062 (diff)
downloadihatemoney-mirror-026a0722357d74b143ed2d974ad2d871a56041b3.zip
ihatemoney-mirror-026a0722357d74b143ed2d974ad2d871a56041b3.tar.gz
ihatemoney-mirror-026a0722357d74b143ed2d974ad2d871a56041b3.tar.bz2
Add Project History Page (#553)
Co-Authored-By: Glandos <bugs-github@antipoul.fr> All project activity can be tracked, using SQLAlchemy-continuum. IP addresses can optionally be recorded.
Diffstat (limited to 'ihatemoney')
-rw-r--r--ihatemoney/forms.py35
-rw-r--r--ihatemoney/history.py139
-rw-r--r--ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py214
-rw-r--r--ihatemoney/migrations/versions/cb038f79982e_sqlite_autoincrement.py50
-rw-r--r--ihatemoney/models.py74
-rw-r--r--ihatemoney/patch_sqlalchemy_continuum.py138
-rw-r--r--ihatemoney/static/css/main.css44
-rw-r--r--ihatemoney/static/images/add.pngbin0 -> 264 bytes
-rw-r--r--ihatemoney/static/images/x.svg8
-rw-r--r--ihatemoney/static/js/ihatemoney.js4
-rw-r--r--ihatemoney/templates/forms.html18
-rw-r--r--ihatemoney/templates/history.html250
-rw-r--r--ihatemoney/templates/layout.html1
-rw-r--r--ihatemoney/tests/tests.py648
-rw-r--r--ihatemoney/utils.py25
-rw-r--r--ihatemoney/versioning.py94
-rw-r--r--ihatemoney/web.py49
17 files changed, 1781 insertions, 10 deletions
diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py
index 62c2144..495eefa 100644
--- a/ihatemoney/forms.py
+++ b/ihatemoney/forms.py
@@ -1,7 +1,7 @@
from flask_wtf.form import FlaskForm
from wtforms.fields.core import SelectField, SelectMultipleField
from wtforms.fields.html5 import DateField, DecimalField, URLField
-from wtforms.fields.simple import PasswordField, SubmitField, StringField
+from wtforms.fields.simple import PasswordField, SubmitField, StringField, BooleanField
from wtforms.validators import (
Email,
DataRequired,
@@ -14,7 +14,7 @@ from flask_wtf.file import FileField, FileAllowed, FileRequired
from flask_babel import lazy_gettext as _
from flask import request
-from werkzeug.security import generate_password_hash
+from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
from re import match
@@ -22,7 +22,7 @@ from jinja2 import Markup
import email_validator
-from ihatemoney.models import Project, Person
+from ihatemoney.models import Project, Person, LoggingMode
from ihatemoney.utils import slugify, eval_arithmetic_expression
@@ -89,6 +89,19 @@ class EditProjectForm(FlaskForm):
name = StringField(_("Project name"), validators=[DataRequired()])
password = StringField(_("Private code"), validators=[DataRequired()])
contact_email = StringField(_("Email"), validators=[DataRequired(), Email()])
+ project_history = BooleanField(_("Enable project history"))
+ ip_recording = BooleanField(_("Use IP tracking for project history"))
+
+ @property
+ def logging_preference(self):
+ """Get the LoggingMode object corresponding to current form data."""
+ if not self.project_history.data:
+ return LoggingMode.DISABLED
+ else:
+ if self.ip_recording.data:
+ return LoggingMode.RECORD_IP
+ else:
+ return LoggingMode.ENABLED
def save(self):
"""Create a new project with the information given by this form.
@@ -100,14 +113,20 @@ class EditProjectForm(FlaskForm):
id=self.id.data,
password=generate_password_hash(self.password.data),
contact_email=self.contact_email.data,
+ logging_preference=self.logging_preference,
)
return project
def update(self, project):
"""Update the project with the information from the form"""
project.name = self.name.data
- project.password = generate_password_hash(self.password.data)
+
+ # Only update password if changed to prevent spurious log entries
+ if not check_password_hash(project.password, self.password.data):
+ project.password = generate_password_hash(self.password.data)
+
project.contact_email = self.contact_email.data
+ project.logging_preference = self.logging_preference
return project
@@ -126,6 +145,14 @@ class ProjectForm(EditProjectForm):
password = PasswordField(_("Private code"), validators=[DataRequired()])
submit = SubmitField(_("Create the project"))
+ def save(self):
+ # WTForms Boolean Fields don't insert the default value when the
+ # request doesn't include any value the way that other fields do,
+ # so we'll manually do it here
+ self.project_history.data = LoggingMode.default() != LoggingMode.DISABLED
+ self.ip_recording.data = LoggingMode.default() == LoggingMode.RECORD_IP
+ return super().save()
+
def validate_id(form, field):
form.id.data = slugify(field.data)
if (form.id.data == "dashboard") or Project.query.get(form.id.data):
diff --git a/ihatemoney/history.py b/ihatemoney/history.py
new file mode 100644
index 0000000..cda141e
--- /dev/null
+++ b/ihatemoney/history.py
@@ -0,0 +1,139 @@
+from flask_babel import gettext as _
+from sqlalchemy_continuum import (
+ Operation,
+ parent_class,
+)
+
+from ihatemoney.models import (
+ PersonVersion,
+ ProjectVersion,
+ BillVersion,
+ Person,
+)
+
+
+def get_history_queries(project):
+ """Generate queries for each type of version object for a given project."""
+ person_changes = PersonVersion.query.filter_by(project_id=project.id)
+
+ project_changes = ProjectVersion.query.filter_by(id=project.id)
+
+ bill_changes = (
+ BillVersion.query.with_entities(BillVersion.id.label("bill_version_id"))
+ .join(Person, BillVersion.payer_id == Person.id)
+ .filter(Person.project_id == project.id)
+ )
+ sub_query = bill_changes.subquery()
+ bill_changes = BillVersion.query.filter(BillVersion.id.in_(sub_query))
+
+ return person_changes, project_changes, bill_changes
+
+
+def history_sort_key(history_item_dict):
+ """
+ Return the key necessary to sort history entries. First order sort is time
+ of modification, but for simultaneous modifications we make the re-name
+ modification occur last so that the simultaneous entries make sense using
+ the old name.
+ """
+ second_order = 0
+ if "prop_changed" in history_item_dict:
+ changed_property = history_item_dict["prop_changed"]
+ if changed_property == "name" or changed_property == "what":
+ second_order = 1
+
+ return history_item_dict["time"], second_order
+
+
+def describe_version(version_obj):
+ """Use the base model str() function to describe a version object"""
+ return parent_class(type(version_obj)).__str__(version_obj)
+
+
+def describe_owers_change(version, human_readable_names):
+ """Compute the set difference to get added/removed owers lists."""
+ before_owers = {version.id: version for version in version.previous.owers}
+ after_owers = {version.id: version for version in version.owers}
+
+ added_ids = set(after_owers).difference(set(before_owers))
+ removed_ids = set(before_owers).difference(set(after_owers))
+
+ if not human_readable_names:
+ return added_ids, removed_ids
+
+ added = [describe_version(after_owers[ower_id]) for ower_id in added_ids]
+ removed = [describe_version(before_owers[ower_id]) for ower_id in removed_ids]
+
+ return added, removed
+
+
+def get_history(project, human_readable_names=True):
+ """
+ Fetch history for all models associated with a given project.
+ :param human_readable_names Whether to replace id numbers with readable names
+ :return A sorted list of dicts with history information
+ """
+ person_query, project_query, bill_query = get_history_queries(project)
+ history = []
+ for version_list in [person_query.all(), project_query.all(), bill_query.all()]:
+ for version in version_list:
+ object_type = {
+ "Person": _("Person"),
+ "Bill": _("Bill"),
+ "Project": _("Project"),
+ }[parent_class(type(version)).__name__]
+
+ # Use the old name if applicable
+ if version.previous:
+ object_str = describe_version(version.previous)
+ else:
+ object_str = describe_version(version)
+
+ common_properties = {
+ "time": version.transaction.issued_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "operation_type": version.operation_type,
+ "object_type": object_type,
+ "object_desc": object_str,
+ "ip": version.transaction.remote_addr,
+ }
+
+ if version.operation_type == Operation.UPDATE:
+ # Only iterate the changeset if the previous version
+ # Was logged
+ if version.previous:
+ changeset = version.changeset
+ if isinstance(version, BillVersion):
+ if version.owers != version.previous.owers:
+ added, removed = describe_owers_change(
+ version, human_readable_names
+ )
+
+ if added:
+ changeset["owers_added"] = (None, added)
+ if removed:
+ changeset["owers_removed"] = (None, removed)
+
+ for (prop, (val_before, val_after),) in changeset.items():
+ if human_readable_names:
+ if prop == "payer_id":
+ prop = "payer"
+ if val_after is not None:
+ val_after = describe_version(version.payer)
+ if version.previous and val_before is not None:
+ val_before = describe_version(
+ version.previous.payer
+ )
+ else:
+ val_after = None
+
+ next_event = common_properties.copy()
+ next_event["prop_changed"] = prop
+ next_event["val_before"] = val_before
+ next_event["val_after"] = val_after
+ history.append(next_event)
+ else:
+ history.append(common_properties)
+ else:
+ history.append(common_properties)
+
+ return sorted(history, key=history_sort_key, reverse=True)
diff --git a/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py b/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py
new file mode 100644
index 0000000..0800835
--- /dev/null
+++ b/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py
@@ -0,0 +1,214 @@
+"""autologger
+
+Revision ID: 2dcb0c0048dc
+Revises: 6c6fb2b7f229
+Create Date: 2020-04-10 18:12:41.285590
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = "2dcb0c0048dc"
+down_revision = "6c6fb2b7f229"
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "bill_version",
+ sa.Column("id", sa.Integer(), autoincrement=False, nullable=False),
+ sa.Column("payer_id", sa.Integer(), autoincrement=False, nullable=True),
+ sa.Column("amount", sa.Float(), autoincrement=False, nullable=True),
+ sa.Column("date", sa.Date(), autoincrement=False, nullable=True),
+ sa.Column("creation_date", sa.Date(), autoincrement=False, nullable=True),
+ sa.Column("what", sa.UnicodeText(), autoincrement=False, nullable=True),
+ sa.Column(
+ "external_link", sa.UnicodeText(), autoincrement=False, nullable=True
+ ),
+ sa.Column("archive", sa.Integer(), autoincrement=False, nullable=True),
+ 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("id", "transaction_id"),
+ )
+ op.create_index(
+ op.f("ix_bill_version_end_transaction_id"),
+ "bill_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_bill_version_operation_type"),
+ "bill_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_bill_version_transaction_id"),
+ "bill_version",
+ ["transaction_id"],
+ unique=False,
+ )
+ op.create_table(
+ "billowers_version",
+ sa.Column("bill_id", sa.Integer(), autoincrement=False, nullable=False),
+ sa.Column("person_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", "person_id", "transaction_id"),
+ )
+ op.create_index(
+ op.f("ix_billowers_version_end_transaction_id"),
+ "billowers_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_billowers_version_operation_type"),
+ "billowers_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_billowers_version_transaction_id"),
+ "billowers_version",
+ ["transaction_id"],
+ unique=False,
+ )
+ op.create_table(
+ "person_version",
+ sa.Column("id", sa.Integer(), autoincrement=False, nullable=False),
+ sa.Column(
+ "project_id", sa.String(length=64), autoincrement=False, nullable=True
+ ),
+ sa.Column("name", sa.UnicodeText(), autoincrement=False, nullable=True),
+ sa.Column("weight", sa.Float(), autoincrement=False, nullable=True),
+ sa.Column("activated", sa.Boolean(), autoincrement=False, nullable=True),
+ 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("id", "transaction_id"),
+ )
+ op.create_index(
+ op.f("ix_person_version_end_transaction_id"),
+ "person_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_person_version_operation_type"),
+ "person_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_person_version_transaction_id"),
+ "person_version",
+ ["transaction_id"],
+ unique=False,
+ )
+ op.create_table(
+ "project_version",
+ sa.Column("id", sa.String(length=64), autoincrement=False, nullable=False),
+ sa.Column("name", sa.UnicodeText(), autoincrement=False, nullable=True),
+ sa.Column(
+ "password", sa.String(length=128), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "contact_email", sa.String(length=128), autoincrement=False, nullable=True
+ ),
+ sa.Column(
+ "logging_preference",
+ sa.Enum("DISABLED", "ENABLED", "RECORD_IP", name="loggingmode"),
+ server_default="ENABLED",
+ autoincrement=False,
+ nullable=True,
+ ),
+ 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("id", "transaction_id"),
+ )
+ op.create_index(
+ op.f("ix_project_version_end_transaction_id"),
+ "project_version",
+ ["end_transaction_id"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_project_version_operation_type"),
+ "project_version",
+ ["operation_type"],
+ unique=False,
+ )
+ op.create_index(
+ op.f("ix_project_version_transaction_id"),
+ "project_version",
+ ["transaction_id"],
+ unique=False,
+ )
+ op.create_table(
+ "transaction",
+ sa.Column("issued_at", sa.DateTime(), nullable=True),
+ sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
+ sa.Column("remote_addr", sa.String(length=50), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.add_column(
+ "project",
+ sa.Column(
+ "logging_preference",
+ sa.Enum("DISABLED", "ENABLED", "RECORD_IP", name="loggingmode"),
+ server_default="ENABLED",
+ nullable=False,
+ ),
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column("project", "logging_preference")
+ op.drop_table("transaction")
+ op.drop_index(
+ op.f("ix_project_version_transaction_id"), table_name="project_version"
+ )
+ op.drop_index(
+ op.f("ix_project_version_operation_type"), table_name="project_version"
+ )
+ op.drop_index(
+ op.f("ix_project_version_end_transaction_id"), table_name="project_version"
+ )
+ op.drop_table("project_version")
+ op.drop_index(op.f("ix_person_version_transaction_id"), table_name="person_version")
+ op.drop_index(op.f("ix_person_version_operation_type"), table_name="person_version")
+ op.drop_index(
+ op.f("ix_person_version_end_transaction_id"), table_name="person_version"
+ )
+ op.drop_table("person_version")
+ op.drop_index(
+ op.f("ix_billowers_version_transaction_id"), table_name="billowers_version"
+ )
+ op.drop_index(
+ op.f("ix_billowers_version_operation_type"), table_name="billowers_version"
+ )
+ op.drop_index(
+ op.f("ix_billowers_version_end_transaction_id"), table_name="billowers_version"
+ )
+ op.drop_table("billowers_version")
+ op.drop_index(op.f("ix_bill_version_transaction_id"), table_name="bill_version")
+ op.drop_index(op.f("ix_bill_version_operation_type"), table_name="bill_version")
+ op.drop_index(op.f("ix_bill_version_end_transaction_id"), table_name="bill_version")
+ op.drop_table("bill_version")
+ # ### end Alembic commands ###
diff --git a/ihatemoney/migrations/versions/cb038f79982e_sqlite_autoincrement.py b/ihatemoney/migrations/versions/cb038f79982e_sqlite_autoincrement.py
new file mode 100644
index 0000000..ae5ab32
--- /dev/null
+++ b/ihatemoney/migrations/versions/cb038f79982e_sqlite_autoincrement.py
@@ -0,0 +1,50 @@
+"""sqlite_autoincrement
+
+Revision ID: cb038f79982e
+Revises: 2dcb0c0048dc
+Create Date: 2020-04-13 17:40:02.426957
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = "cb038f79982e"
+down_revision = "2dcb0c0048dc"
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ alter_table_batches = [
+ op.batch_alter_table(
+ "person", recreate="always", table_kwargs={"sqlite_autoincrement": True}
+ ),
+ op.batch_alter_table(
+ "bill", recreate="always", table_kwargs={"sqlite_autoincrement": True}
+ ),
+ op.batch_alter_table(
+ "billowers", recreate="always", table_kwargs={"sqlite_autoincrement": True}
+ ),
+ ]
+
+ for batch_op in alter_table_batches:
+ with batch_op:
+ pass
+
+
+def downgrade():
+ alter_table_batches = [
+ op.batch_alter_table(
+ "person", recreate="always", table_kwargs={"sqlite_autoincrement": False}
+ ),
+ op.batch_alter_table(
+ "bill", recreate="always", table_kwargs={"sqlite_autoincrement": False}
+ ),
+ op.batch_alter_table(
+ "billowers", recreate="always", table_kwargs={"sqlite_autoincrement": False}
+ ),
+ ]
+
+ for batch_op in alter_table_batches:
+ with batch_op:
+ pass
diff --git a/ihatemoney/models.py b/ihatemoney/models.py
index 4d32fd9..d765c93 100644
--- a/ihatemoney/models.py
+++ b/ihatemoney/models.py
@@ -1,6 +1,8 @@
from collections import defaultdict
from datetime import datetime
+
+import sqlalchemy
from flask_sqlalchemy import SQLAlchemy, BaseQuery
from flask import g, current_app
@@ -13,6 +15,40 @@ from itsdangerous import (
BadSignature,
SignatureExpired,
)
+from sqlalchemy_continuum import make_versioned
+from sqlalchemy_continuum.plugins import FlaskPlugin
+from sqlalchemy_continuum import version_class
+
+from ihatemoney.patch_sqlalchemy_continuum import PatchedBuilder
+from ihatemoney.versioning import (
+ LoggingMode,
+ ConditionalVersioningManager,
+ version_privacy_predicate,
+ get_ip_if_allowed,
+)
+
+
+make_versioned(
+ user_cls=None,
+ manager=ConditionalVersioningManager(
+ # Conditionally Disable the versioning based on each
+ # project's privacy preferences
+ tracking_predicate=version_privacy_predicate,
+ # Patch in a fix to a SQLAchemy-Continuum Bug.
+ # See patch_sqlalchemy_continuum.py
+ builder=PatchedBuilder(),
+ ),
+ plugins=[
+ FlaskPlugin(
+ # Redirect to our own function, which respects user preferences
+ # on IP address collection
+ remote_addr_factory=get_ip_if_allowed,
+ # Suppress the plugin's attempt to grab a user id,
+ # which imports the flask_login module (causing an error)
+ current_user_id_factory=lambda: None,
+ )
+ ],
+)
db = SQLAlchemy()
@@ -22,11 +58,20 @@ class Project(db.Model):
def get_by_name(self, name):
return Project.query.filter(Project.name == name).one()
+ # Direct SQLAlchemy-Continuum to track changes to this model
+ __versioned__ = {}
+
id = db.Column(db.String(64), primary_key=True)
name = db.Column(db.UnicodeText)
password = db.Column(db.String(128))
contact_email = db.Column(db.String(128))
+ logging_preference = db.Column(
+ db.Enum(LoggingMode),
+ default=LoggingMode.default(),
+ nullable=False,
+ server_default=LoggingMode.default().name,
+ )
members = db.relationship("Person", backref="project")
query_class = ProjectQuery
@@ -37,6 +82,7 @@ class Project(db.Model):
"id": self.id,
"name": self.name,
"contact_email": self.contact_email,
+ "logging_preference": self.logging_preference.value,
"members": [],
}
@@ -277,6 +323,9 @@ class Project(db.Model):
return None
return data["project_id"]
+ def __str__(self):
+ return self.name
+
def __repr__(self):
return "<Project %s>" % self.name
@@ -301,6 +350,11 @@ class Person(db.Model):
query_class = PersonQuery
+ # Direct SQLAlchemy-Continuum to track changes to this model
+ __versioned__ = {}
+
+ __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="payer")
@@ -337,8 +391,9 @@ 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")),
- db.Column("person_id", db.Integer, db.ForeignKey("person.id")),
+ 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,
)
@@ -365,6 +420,11 @@ class Bill(db.Model):
query_class = BillQuery
+ # Direct SQLAlchemy-Continuum to track changes to this model
+ __versioned__ = {}
+
+ __table_args__ = {"sqlite_autoincrement": True}
+
id = db.Column(db.Integer, primary_key=True)
payer_id = db.Column(db.Integer, db.ForeignKey("person.id"))
@@ -403,6 +463,9 @@ class Bill(db.Model):
else:
return 0
+ def __str__(self):
+ return "%s for %s" % (self.amount, self.what)
+
def __repr__(self):
return "<Bill of %s from %s for %s>" % (
self.amount,
@@ -426,3 +489,10 @@ class Archive(db.Model):
def __repr__(self):
return "<Archive>"
+
+
+sqlalchemy.orm.configure_mappers()
+
+PersonVersion = version_class(Person)
+ProjectVersion = version_class(Project)
+BillVersion = version_class(Bill)
diff --git a/ihatemoney/patch_sqlalchemy_continuum.py b/ihatemoney/patch_sqlalchemy_continuum.py
new file mode 100644
index 0000000..e0680c6
--- /dev/null
+++ b/ihatemoney/patch_sqlalchemy_continuum.py
@@ -0,0 +1,138 @@
+"""
+A temporary work-around to patch SQLAlchemy-continuum per:
+https://github.com/kvesteri/sqlalchemy-continuum/pull/242
+
+Source code reproduced under their license:
+
+ Copyright (c) 2012, Konsta Vesterinen
+
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+ * The names of the contributors may not be used to endorse or promote products
+ derived from this software without specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT,
+ INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+ BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
+ OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+"""
+
+import sqlalchemy as sa
+from sqlalchemy_continuum import Operation
+from sqlalchemy_continuum.builder import Builder
+from sqlalchemy_continuum.expression_reflector import VersionExpressionReflector
+from sqlalchemy_continuum.relationship_builder import RelationshipBuilder
+from sqlalchemy_continuum.utils import option, adapt_columns
+
+
+class PatchedRelationShipBuilder(RelationshipBuilder):
+ def association_subquery(self, obj):
+ """
+ Returns an EXISTS clause that checks if an association exists for given
+ SQLAlchemy declarative object. This query is used by
+ many_to_many_criteria method.
+
+ Example query:
+
+ .. code-block:: sql
+
+ EXISTS (
+ SELECT 1
+ FROM article_tag_version
+ WHERE article_id = 3
+ AND tag_id = tags_version.id
+ AND operation_type != 2
+ AND EXISTS (
+ SELECT 1
+ FROM article_tag_version as article_tag_version2
+ WHERE article_tag_version2.tag_id = article_tag_version.tag_id
+ AND article_tag_version2.tx_id <=5
+ AND article_tag_version2.article_id = 3
+ GROUP BY article_tag_version2.tag_id
+ HAVING
+ MAX(article_tag_version2.tx_id) =
+ article_tag_version.tx_id
+ )
+ )
+
+ :param obj: SQLAlchemy declarative object
+ """
+
+ tx_column = option(obj, "transaction_column_name")
+ join_column = self.property.primaryjoin.right.name
+ object_join_column = self.property.primaryjoin.left.name
+ reflector = VersionExpressionReflector(obj, self.property)
+
+ association_table_alias = self.association_version_table.alias()
+ association_cols = [
+ association_table_alias.c[association_col.name]
+ for _, association_col in self.remote_to_association_column_pairs
+ ]
+
+ association_exists = sa.exists(
+ sa.select([1])
+ .where(
+ sa.and_(
+ association_table_alias.c[tx_column] <= getattr(obj, tx_column),
+ association_table_alias.c[join_column]
+ == getattr(obj, object_join_column),
+ *[
+ association_col
+ == self.association_version_table.c[association_col.name]
+ for association_col in association_cols
+ ]
+ )
+ )
+ .group_by(*association_cols)
+ .having(
+ sa.func.max(association_table_alias.c[tx_column])
+ == self.association_version_table.c[tx_column]
+ )
+ .correlate(self.association_version_table)
+ )
+ return sa.exists(
+ sa.select([1])
+ .where(
+ sa.and_(
+ reflector(self.property.primaryjoin),
+ association_exists,
+ self.association_version_table.c.operation_type != Operation.DELETE,
+ adapt_columns(self.property.secondaryjoin),
+ )
+ )
+ .correlate(self.local_cls, self.remote_cls)
+ )
+
+
+class PatchedBuilder(Builder):
+ def build_relationships(self, version_classes):
+ """
+ Builds relationships for all version classes.
+
+ :param version_classes: list of generated version classes
+ """
+ for cls in version_classes:
+ if not self.manager.option(cls, "versioning"):
+ continue
+
+ for prop in sa.inspect(cls).iterate_properties:
+ if prop.key == "versions":
+ continue
+ builder = PatchedRelationShipBuilder(self.manager, cls, prop)
+ builder()
diff --git a/ihatemoney/static/css/main.css b/ihatemoney/static/css/main.css
index fe8eec2..7d91c38 100644
--- a/ihatemoney/static/css/main.css
+++ b/ihatemoney/static/css/main.css
@@ -326,7 +326,8 @@ footer .footer-left {
}
#bill_table,
-#monthly_stats {
+#monthly_stats,
+#history_table {
margin-top: 30px;
margin-bottom: 30px;
}
@@ -363,6 +364,36 @@ footer .footer-left {
background: url("../images/see.png") no-repeat right;
}
+.history_icon > .delete,
+.history_icon > .add,
+.history_icon > .edit {
+ font-size: 0px;
+ display: block;
+ width: 16px;
+ height: 16px;
+ margin: 2px;
+ margin-right: 10px;
+ margin-top: 3px;
+ float: left;
+}
+
+.history_icon > .delete {
+ background: url("../images/delete.png") no-repeat right;
+}
+
+.history_icon > .edit {
+ background: url("../images/edit.png") no-repeat right;
+}
+
+.history_icon > .add {
+ background: url("../images/add.png") no-repeat right;
+}
+
+.history_text {
+ display: table-cell;
+}
+
+
.balance .balance-value {
text-align: right;
}
@@ -518,12 +549,23 @@ footer .icon svg {
fill: white;
}
+.icon.icon-red {
+ fill: #dc3545;
+}
+.btn:hover .icon.icon-red {
+ fill: white !important;
+}
+
/* align the first column */
#monthly_stats tr *:first-child {
text-align: right;
width: 200px;
}
+#history_warnings {
+ margin-top: 30px;
+}
+
/* edit settings */
.edit-project form {
diff --git a/ihatemoney/static/images/add.png b/ihatemoney/static/images/add.png
new file mode 100644
index 0000000..262891b
--- /dev/null
+++ b/ihatemoney/static/images/add.png
Binary files differ
diff --git a/ihatemoney/static/images/x.svg b/ihatemoney/static/images/x.svg
new file mode 100644
index 0000000..3416d7a
--- /dev/null
+++ b/ihatemoney/static/images/x.svg
@@ -0,0 +1,8 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 807 807"><g
+ transform="translate(-20,-20)"
+ id="g8"><polygon
+ id="polygon6"
+ points="173,20 423,269 673,20 827,173 577,423 827,673 673,827 423,577 173,827 20,673 269,423 20,173 "
+ class="fil0" /></g></svg> \ No newline at end of file
diff --git a/ihatemoney/static/js/ihatemoney.js b/ihatemoney/static/js/ihatemoney.js
index c240dc1..9ffc877 100644
--- a/ihatemoney/static/js/ihatemoney.js
+++ b/ihatemoney/static/js/ihatemoney.js
@@ -5,3 +5,7 @@ function selectCheckboxes(value){
els[i].checked = value;
}
}
+
+function localizeTime(utcTimestamp) {
+ return new Date(utcTimestamp).toLocaleString()
+}
diff --git a/ihatemoney/templates/forms.html b/ihatemoney/templates/forms.html
index bec7018..33a283f 100644
--- a/ihatemoney/templates/forms.html
+++ b/ihatemoney/templates/forms.html
@@ -20,6 +20,16 @@
</div>
{% endmacro %}
+{% macro checkbox(field) %}
+ <div class="controls{% if inline %} col-9{% endif %}">
+ {{ field(id=field.name) }}
+ <label for="{{ field.name }}">{{ field.label() }}</label>
+ {% if field.description %}
+ <small id="{{field.name}}_description"" class="form-text text-muted">{{ field.description }}</small>
+ {% endif %}
+ </div>
+{% endmacro %}
+
{% macro submit(field, cancel=False, home=False) -%}
<div class="actions">
<button type="submit" class="btn btn-primary">{{ field.name }}</button>
@@ -78,6 +88,14 @@
{{ input(form.name) }}
{{ input(form.password) }}
{{ input(form.contact_email) }}
+ <div class="form-group">
+ <label for="privacy_checkboxes">{{ _("Privacy Settings") }}</label>
+ <div id="privacy_checkboxes" class="card card-body bg-light">
+ {{ checkbox(form.project_history) }}
+ {{ checkbox(form.ip_recording) }}
+ </div>
+ </div>
+
<div class="actions">
<button class="btn btn-primary">{{ _("Edit the project") }}</button>
<a id="delete-project" style="color:red; margin-left:10px; cursor:pointer; ">{{ _("delete") }}</a>
diff --git a/ihatemoney/templates/history.html b/ihatemoney/templates/history.html
new file mode 100644
index 0000000..875040e
--- /dev/null
+++ b/ihatemoney/templates/history.html
@@ -0,0 +1,250 @@
+{% extends "sidebar_table_layout.html" %}
+
+{% macro change_to_logging_preference(event) %}
+{% if event.val_after == LoggingMode.DISABLED %}
+ {% if event.val_before == LoggingMode.ENABLED %}
+ {{ _("Disabled Project History") }}
+ {% else %}
+ {{ _("Disabled Project History & IP Address Recording") }}
+ {% endif %}
+{% elif event.val_after == LoggingMode.ENABLED %}
+ {% if event.val_before == LoggingMode.DISABLED %}
+ {{ _("Enabled Project History") }}
+ {% elif event.val_before == LoggingMode.RECORD_IP %}
+ {{ _("Disabled IP Address Recording") }}
+ {% else %}
+ {{ _("Enabled Project History") }}
+ {% endif %}
+{% elif event.val_after == LoggingMode.RECORD_IP %}
+ {% if event.val_before == LoggingMode.DISABLED %}
+ {{ _("Enabled Project History & IP Address Recording") }}
+ {% elif event.val_before == LoggingMode.ENABLED %}
+ {{ _("Enabled IP Address Recording") }}
+ {% else %}
+ {{ _("Enabled Project History & IP Address Recording") }}
+ {% endif %}
+{% else %}
+ {# Should be unreachable #}
+ {{ _("History Settings Changed") }}
+{% endif %}
+{% endmacro %}
+
+{% macro describe_object(event) %}{{ event.object_type }} <em class="font-italic">{{ event.object_desc }}</em>{% endmacro %}
+
+{% macro simple_property_change(event, localized_property_name, from=True) %}
+ {{ describe_object(event) }}:
+ {{ localized_property_name }} {{ _("changed") }}
+ {% if from %}{{ _("from") }} <em class="font-italic">{{ event.val_before }}</em>{% endif %}
+ {{ _("to") }} <em class="font-italic">{{ event.val_after }}</em>
+{% endmacro %}
+
+{% macro clear_history_modals() %}
+<!-- Modal -->
+<div id="confirm-ip-delete" class="modal fade show" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h3 class="modal-title">{{ _('Confirm Remove IP Adresses') }}</h3>
+ <a href="#" class="close" data-dismiss="modal">&times;</a>
+ </div>
+ <div class="modal-body">
+ <p>{{ _("Are you sure you want to delete all recorded IP addresses from this project?
+ The rest of the project history will be unaffected. This action cannot be undone.") }}</p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-dismiss="modal">{{ _("Close") }}</button>
+ <form action="{{ url_for(".strip_ip_addresses") }}" method="post">
+ <input type="submit" class="btn btn-danger" value="{{ _("Confirm Delete") }}" name="{{ _("Confirm Delete") }}"/>
+ </form>
+ </div>
+ </div>
+ </div>
+</div>
+<!-- Modal -->
+ <div id="confirm-erase" class="modal fade show" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h3 class="modal-title">{{ _('Delete Confirmation') }}</h3>
+ <a href="#" class="close" data-dismiss="modal">&times;</a>
+ </div>
+ <div class="modal-body">
+ <p>{{ _("Are you sure you want to erase all history for this project? This action cannot be undone.") }}</p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-dismiss="modal">{{ _("Close") }}</button>
+ <form action="{{ url_for(".erase_history") }}" method="post">
+ <input type="submit" class="btn btn-danger" value="{{ _("Confirm Delete") }}" name="{{ _("Confirm Delete") }}"/>
+ </form>
+ </div>
+ </div>
+ </div>
+</div>
+{% endmacro %}
+
+{% macro owers_changed(event, add) %}
+ {{ describe_object(event) }}: {% if add %}{{ _("Added") }}{% else %}{{ _("Removed") }}{% endif %}
+ {% if event.val_after|length > 1 %}
+ {% for name in event.val_after %}
+ <em class="font-italic">{{ name }}</em>{% if event.val_after|length > 2 and loop.index != event.val_after|length %},{% endif %}
+ {% if loop.index == event.val_after|length - 1 %} {{ _("and") }} {% endif %}
+ {% endfor %}
+ {% else %}
+ <em class="font-italic">{{ event.val_after[0] }}</em>
+ {% endif %}
+ {% if add %}{{ _("to") }}{% else %}{{ _("from") }}{% endif %}
+ {{ _("owers list") }}
+{% endmacro %}
+
+{% block sidebar %}
+ <div id="table_overflow">
+ <table class="balance table">
+ <thead>
+ <tr class="d-none d-md-table-row">
+ <th>{{ _("Who?") }}</th>
+ <th class="balance-value">{{ _("Balance") }}</th>
+ </tr>
+ </thead>
+ {% set balance = g.project.balance %}
+ {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %}
+ <tr id="bal-member-{{ member.id }}" action={% if member.activated %}delete{% else %}reactivate{% endif %}>
+ <td class="balance-name">{{ member.name }}</td>
+ <td class="balance-value {% if balance[member.id]|round(2) > 0 %}positive{% elif balance[member.id]|round(2) < 0 %}negative{% endif %}">
+ {% if balance[member.id]|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }}
+ </td>
+ </tr>
+ {% endfor %}
+ </table>
+ </div>
+{% endblock %}
+
+
+
+{% block content %}
+ {% if current_log_pref == LoggingMode.DISABLED or (current_log_pref != LoggingMode.RECORD_IP and any_ip_addresses) %}
+ <div id="history_warnings" class="card card-body bg-light">
+ {% if current_log_pref == LoggingMode.DISABLED %}
+ <p>
+ <i>{{ _("This project has history disabled. New actions won't appear below. You can enable history on the") }}</i>
+ <a href="{{ url_for(".edit_project") }}">{{ _("settings page") }}</a>
+ </p>
+ {% if history %}
+ <p><i>{{ _("The table below reflects actions recorded prior to disabling project history. You can ") }}
+ <a href="#" data-toggle="modal" data-keyboard="false" data-target="#confirm-erase">{{ _("clear project history") }}</a> {{ _("to remove them.") }}</i></p>
+ {% endif %}
+ {% endif %}
+ {% if current_log_pref != LoggingMode.RECORD_IP and any_ip_addresses %}
+ <p><i>{{ _("Some entries below contain IP addresses, even though this project has IP recording disabled. ") }}
+ <a href="#" data-toggle="modal" data-keyboard="false" data-target="#confirm-ip-delete">{{ _("Delete stored IP addresses") }}</a></i></p>
+ {% endif %}
+ </div>
+ {% endif %}
+ {{ clear_history_modals() }}
+ <span class="float-right mt-3" {% if not history %} data-toggle="tooltip" title="{{_('No history to erase')}}" {% endif %}>
+ <a href="#" class="btn btn-outline-danger float-right {% if not history %} disabled {% endif %}" data-toggle="modal" data-keyboard="false" data-target="#confirm-erase">
+ <i class="icon icon-red plus">{{ static_include("images/x.svg") | safe }}</i>
+ {{ _("Clear Project History") }}
+ </a>
+ </span>
+ <span class="float-right mt-3" {% if not any_ip_addresses %}data-placement="top" data-toggle="tooltip" title="{{_('No IP Addresses to erase')}}" {% endif %}>
+ <a href="#" class="btn btn-outline-danger float-right mr-2 {% if not any_ip_addresses %} disabled {% endif %}" data-toggle="modal" data-keyboard="false" data-target="#confirm-ip-delete">
+ <i class="icon icon-red plus">{{ static_include("images/x.svg") | safe }}</i>
+ {{ _("Delete Stored IP Addresses") }}
+ </a>
+ </span>
+
+ <div class="clearfix"></div>
+ {% if history %}
+ <table id="history_table" class="split_bills table table-striped">
+ <thead><tr>
+ <th style="width: 15%">{{ _("Time") }}</th>
+ <th style="width: 65%">{{ _("Event") }}</th>
+ <th style="width: 20%">
+ <span data-toggle="tooltip" title="{{_('IP address recording can be') }}
+ {% if current_log_pref != LoggingMode.RECORD_IP %}
+ {{ _("enabled") }}{% else %}{{ _("disabled") }}{% endif %}
+ {{ _('on the Settings page') }}">
+ {{ _("From IP") }}</span></th>
+ </tr></thead>
+ <tbody>
+ {% for event in history %}
+ <tr>
+ <td><script>document.write(localizeTime("{{ event.time }}"));</script></td>
+ <td >
+ <div class="history_icon">
+ <i {% if event.operation_type == OperationType.INSERT %}
+ class="add"
+ {% elif event.operation_type == OperationType.UPDATE %}
+ class="edit"
+ {% elif event.operation_type == OperationType.DELETE %}
+ class="delete"
+ {% endif %}
+ ></i>
+ </div>
+ <div class="history_text">
+ {% if event.operation_type == OperationType.INSERT %}
+ {{ event.object_type }} <em class="font-italic">{{ event.object_desc }}</em> {{ _("added") }}
+ {% elif event.operation_type == OperationType.UPDATE %}
+ {% if event.object_type == _("Project") %}
+ {% if event.prop_changed == "password" %}
+ {{ _("Project private code changed") }}
+ {% elif event.prop_changed == "logging_preference" %}
+ {{ change_to_logging_preference(event) }}
+ {% elif event.prop_changed == "name" %}
+ {{ _("Project renamed to") }} <em class="font-italic">{{ event.val_after }}</em>
+ {% elif event.prop_changed == "contact_email" %}
+ {{ _("Project contact email changed to") }} <em class="font-italic">{{ event.val_after }}</em>
+ {% else %}
+ {{ _("Project settings modified") }}
+ {% endif %}
+ {% elif event.prop_changed == "activated" %}
+ {{ event.object_type }} <em class="font-italic">{{ event.object_desc }}</em>
+ {% if event.val_after == False %}{{ _("deactivated") }}{% else %}{{ _("reactivated") }}{% endif %}
+ {% elif event.prop_changed == "name" or event.prop_changed == "what" %}
+ {{ describe_object(event) }} {{ _("renamed") }} {{ _("to") }} <em class="font-italic">{{ event.val_after }}</em>
+ {% elif event.prop_changed == "weight" %}
+ {{ simple_property_change(event, _("Weight")) }}
+ {% elif event.prop_changed == "external_link" %}
+ {{ describe_object(event) }}: {{ _("External link changed to") }}
+ <a href="{{ event.val_after }}" class="font-italic">{{ event.val_after }}</a>
+ {% elif event.prop_changed == "owers_added" %}
+ {{ owers_changed(event, True)}}
+ {% elif event.prop_changed == "owers_removed" %}
+ {{ owers_changed(event, False)}}
+ {% elif event.prop_changed == "payer" %}
+ {{ simple_property_change(event, _("Payer"))}}
+ {% elif event.prop_changed == "amount" %}
+ {{ simple_property_change(event, _("Amount")) }}
+ {% elif event.prop_changed == "date" %}
+ {{ simple_property_change(event, _("Date")) }}
+ {% else %}
+ {{ describe_object(event) }} {{ _("modified") }}
+ {% endif %}
+ {% elif event.operation_type == OperationType.DELETE %}
+ {{ event.object_type }} <em class="font-italic">{{ event.object_desc }}</em> {{ _("removed") }}
+ {% else %}
+ {# Should be unreachable #}
+ {{ describe_object(event) }} {{ _("changed in a unknown way") }}
+ {% endif %}
+ </div>
+ </td>
+ <td>{% if event.ip %}{{ event.ip }}{% else %} -- {% endif %}</td>
+ </tr>
+ {% 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 hand-holding-heart">{{ static_include("images/hand-holding-heart.svg") | safe }}</i>
+ <h3>{{ _('Nothing to list')}}</h3>
+ <p>
+ {{ _("Someone probably") }}<br />
+ {{ _("cleared the project history.") }}
+ </p>
+ </div>
+ </div></div>
+ {% endif %}
+
+{% endblock %}
diff --git a/ihatemoney/templates/layout.html b/ihatemoney/templates/layout.html
index 8609779..eaf13a6 100644
--- a/ihatemoney/templates/layout.html
+++ b/ihatemoney/templates/layout.html
@@ -45,6 +45,7 @@
<li class="nav-item{% if current_view == 'list_bills' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.list_bills") }}">{{ _("Bills") }}</a></li>
<li class="nav-item{% if current_view == 'settle_bill' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.settle_bill") }}">{{ _("Settle") }}</a></li>
<li class="nav-item{% if current_view == 'statistics' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.statistics") }}">{{ _("Statistics") }}</a></li>
+ <li class="nav-item{% if current_view == 'history' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.history") }}">{{ _("History") }}</a></li>
<li class="nav-item{% if current_view == 'edit_project' %} active{% endif %}"><a class="nav-link" href="{{ url_for("main.edit_project") }}">{{ _("Settings") }}</a></li>
{% endblock %}
{% endif %}
diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py
index c4b1585..5dff64d 100644
--- a/ihatemoney/tests/tests.py
+++ b/ihatemoney/tests/tests.py
@@ -17,7 +17,8 @@ from flask_testing import TestCase
from ihatemoney.run import create_app, db, load_configuration
from ihatemoney.manage import GenerateConfig, GeneratePasswordHash, DeleteProject
-from ihatemoney import models
+from ihatemoney import models, history
+from ihatemoney.versioning import LoggingMode
from ihatemoney import utils
from sqlalchemy import orm
@@ -843,6 +844,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"name": "Super raclette party!",
"contact_email": "alexis@notmyidea.org",
"password": "didoudida",
+ "logging_preference": LoggingMode.ENABLED.value,
}
resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True)
@@ -1517,6 +1519,7 @@ class APITestCase(IhatemoneyTestCase):
"name": "raclette",
"contact_email": "raclette@notmyidea.org",
"id": "raclette",
+ "logging_preference": 1,
}
decoded_resp = json.loads(resp.data.decode("utf-8"))
self.assertDictEqual(decoded_resp, expected)
@@ -1528,6 +1531,7 @@ class APITestCase(IhatemoneyTestCase):
"contact_email": "yeah@notmyidea.org",
"password": "raclette",
"name": "The raclette party",
+ "project_history": "y",
},
headers=self.get_auth("raclette"),
)
@@ -1544,6 +1548,7 @@ class APITestCase(IhatemoneyTestCase):
"contact_email": "yeah@notmyidea.org",
"members": [],
"id": "raclette",
+ "logging_preference": 1,
}
decoded_resp = json.loads(resp.data.decode("utf-8"))
self.assertDictEqual(decoded_resp, expected)
@@ -2104,12 +2109,32 @@ class APITestCase(IhatemoneyTestCase):
"contact_email": "raclette@notmyidea.org",
"id": "raclette",
"name": "raclette",
+ "logging_preference": 1,
}
self.assertStatus(200, req)
decoded_req = json.loads(req.data.decode("utf-8"))
self.assertDictEqual(decoded_req, expected)
+ def test_log_created_from_api_call(self):
+ # create a project
+ self.api_create("raclette")
+ self.login("raclette")
+
+ # add members
+ self.api_add_member("raclette", "alexis")
+
+ resp = self.client.get("/raclette/history", follow_redirects=True)
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ "Person %s added" % em_surround("alexis"), resp.data.decode("utf-8")
+ )
+ self.assertIn(
+ "Project %s added" % em_surround("raclette"), resp.data.decode("utf-8"),
+ )
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
+ self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+
class ServerTestCase(IhatemoneyTestCase):
def test_homepage(self):
@@ -2224,5 +2249,626 @@ class ModelsTestCase(IhatemoneyTestCase):
self.assertEqual(bill.pay_each(), pay_each_expected)
+def em_surround(string, regex_escape=False):
+ if regex_escape:
+ return r'<em class="font-italic">%s<\/em>' % string
+ else:
+ return '<em class="font-italic">%s</em>' % string
+
+
+class HistoryTestCase(IhatemoneyTestCase):
+ def setUp(self):
+ super().setUp()
+ self.post_project("demo")
+ self.login("demo")
+
+ def test_simple_create_logentry_no_ip(self):
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ "Project %s added" % em_surround("demo"), resp.data.decode("utf-8"),
+ )
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
+ self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+
+ def change_privacy_to(self, logging_preference):
+ # Change only logging_preferences
+ new_data = {
+ "name": "demo",
+ "contact_email": "demo@notmyidea.org",
+ "password": "demo",
+ }
+
+ if logging_preference != LoggingMode.DISABLED:
+ new_data["project_history"] = "y"
+ if logging_preference == LoggingMode.RECORD_IP:
+ new_data["ip_recording"] = "y"
+
+ # Disable History
+ resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
+ self.assertEqual(resp.status_code, 200)
+ self.assertNotIn("danger", resp.data.decode("utf-8"))
+
+ resp = self.client.get("/demo/edit")
+ self.assertEqual(resp.status_code, 200)
+ if logging_preference == LoggingMode.DISABLED:
+ self.assertIn('<input id="project_history"', resp.data.decode("utf-8"))
+ else:
+ self.assertIn(
+ '<input checked id="project_history"', resp.data.decode("utf-8")
+ )
+
+ if logging_preference == LoggingMode.RECORD_IP:
+ self.assertIn('<input checked id="ip_recording"', resp.data.decode("utf-8"))
+ else:
+ self.assertIn('<input id="ip_recording"', resp.data.decode("utf-8"))
+
+ def assert_empty_history_logging_disabled(self):
+ resp = self.client.get("/demo/history")
+ self.assertIn(
+ "This project has history disabled. New actions won't appear below. ",
+ resp.data.decode("utf-8"),
+ )
+ self.assertIn(
+ "Nothing to list", resp.data.decode("utf-8"),
+ )
+ self.assertNotIn(
+ "The table below reflects actions recorded prior to disabling project history.",
+ resp.data.decode("utf-8"),
+ )
+ self.assertNotIn(
+ "Some entries below contain IP addresses,", resp.data.decode("utf-8"),
+ )
+ self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+ self.assertNotIn("<td> -- </td>", resp.data.decode("utf-8"))
+ self.assertNotIn(
+ "Project %s added" % em_surround("demo"), resp.data.decode("utf-8")
+ )
+
+ def test_project_edit(self):
+ new_data = {
+ "name": "demo2",
+ "contact_email": "demo2@notmyidea.org",
+ "password": "123456",
+ "project_history": "y",
+ }
+
+ resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
+ self.assertEqual(resp.status_code, 200)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ "Project %s added" % em_surround("demo"), resp.data.decode("utf-8")
+ )
+ self.assertIn(
+ "Project contact email changed to %s" % em_surround("demo2@notmyidea.org"),
+ resp.data.decode("utf-8"),
+ )
+ self.assertIn(
+ "Project private code changed", resp.data.decode("utf-8"),
+ )
+ self.assertIn(
+ "Project renamed to %s" % em_surround("demo2"), resp.data.decode("utf-8"),
+ )
+ self.assertLess(
+ resp.data.decode("utf-8").index("Project renamed "),
+ resp.data.decode("utf-8").index("Project contact email changed to "),
+ )
+ self.assertLess(
+ resp.data.decode("utf-8").index("Project renamed "),
+ resp.data.decode("utf-8").index("Project private code changed"),
+ )
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 4)
+ self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+
+ def test_project_privacy_edit(self):
+ resp = self.client.get("/demo/edit")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ '<input checked id="project_history" name="project_history" type="checkbox" value="y">',
+ resp.data.decode("utf-8"),
+ )
+
+ self.change_privacy_to(LoggingMode.DISABLED)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn("Disabled Project History\n", resp.data.decode("utf-8"))
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
+ self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+
+ self.change_privacy_to(LoggingMode.RECORD_IP)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ "Enabled Project History & IP Address Recording", resp.data.decode("utf-8")
+ )
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
+ self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1)
+
+ self.change_privacy_to(LoggingMode.ENABLED)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn("Disabled IP Address Recording\n", resp.data.decode("utf-8"))
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
+ self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
+
+ def test_project_privacy_edit2(self):
+ self.change_privacy_to(LoggingMode.RECORD_IP)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn("Enabled IP Address Recording\n", resp.data.decode("utf-8"))
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
+ self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 1)
+
+ self.change_privacy_to(LoggingMode.DISABLED)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ "Disabled Project History & IP Address Recording", resp.data.decode("utf-8")
+ )
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
+ self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
+
+ self.change_privacy_to(LoggingMode.ENABLED)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn("Enabled Project History\n", resp.data.decode("utf-8"))
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
+ self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 2)
+
+ def do_misc_database_operations(self, logging_mode):
+ new_data = {
+ "name": "demo2",
+ "contact_email": "demo2@notmyidea.org",
+ "password": "123456",
+ }
+
+ # Keep privacy settings where they were
+ if logging_mode != LoggingMode.DISABLED:
+ new_data["project_history"] = "y"
+ if logging_mode == LoggingMode.RECORD_IP:
+ new_data["ip_recording"] = "y"
+
+ resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
+ self.assertEqual(resp.status_code, 200)
+
+ # adds a member to this project
+ resp = self.client.post(
+ "/demo/members/add", data={"name": "alexis"}, follow_redirects=True
+ )
+ self.assertEqual(resp.status_code, 200)
+
+ user_id = models.Person.query.one().id
+
+ # create a bill
+ resp = self.client.post(
+ "/demo/add",
+ data={
+ "date": "2011-08-10",
+ "what": "fromage à raclette",
+ "payer": user_id,
+ "payed_for": [user_id],
+ "amount": "25",
+ },
+ follow_redirects=True,
+ )
+ self.assertEqual(resp.status_code, 200)
+
+ bill_id = models.Bill.query.one().id
+
+ # edit the bill
+ resp = self.client.post(
+ "/demo/edit/%i" % bill_id,
+ data={
+ "date": "2011-08-10",
+ "what": "fromage à raclette",
+ "payer": user_id,
+ "payed_for": [user_id],
+ "amount": "10",
+ },
+ follow_redirects=True,
+ )
+ self.assertEqual(resp.status_code, 200)
+ # delete the bill
+ resp = self.client.get("/demo/delete/%i" % bill_id, follow_redirects=True)
+ self.assertEqual(resp.status_code, 200)
+
+ # delete user using POST method
+ resp = self.client.post(
+ "/demo/members/%i/delete" % user_id, follow_redirects=True
+ )
+ self.assertEqual(resp.status_code, 200)
+
+ def test_disable_clear_no_new_records(self):
+ # Disable logging
+ self.change_privacy_to(LoggingMode.DISABLED)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ "This project has history disabled. New actions won't appear below. ",
+ resp.data.decode("utf-8"),
+ )
+ self.assertIn(
+ "The table below reflects actions recorded prior to disabling project history.",
+ resp.data.decode("utf-8"),
+ )
+ self.assertNotIn(
+ "Nothing to list", resp.data.decode("utf-8"),
+ )
+ self.assertNotIn(
+ "Some entries below contain IP addresses,", resp.data.decode("utf-8"),
+ )
+
+ # Clear Existing Entries
+ resp = self.client.post("/demo/erase_history", follow_redirects=True)
+ self.assertEqual(resp.status_code, 200)
+ self.assert_empty_history_logging_disabled()
+
+ # Do lots of database operations & check that there's still no history
+ self.do_misc_database_operations(LoggingMode.DISABLED)
+
+ self.assert_empty_history_logging_disabled()
+
+ def test_clear_ip_records(self):
+ # Enable IP Recording
+ self.change_privacy_to(LoggingMode.RECORD_IP)
+
+ # Do lots of database operations to generate IP address entries
+ self.do_misc_database_operations(LoggingMode.RECORD_IP)
+
+ # Disable IP Recording
+ self.change_privacy_to(LoggingMode.ENABLED)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertNotIn(
+ "This project has history disabled. New actions won't appear below. ",
+ resp.data.decode("utf-8"),
+ )
+ self.assertNotIn(
+ "The table below reflects actions recorded prior to disabling project history.",
+ resp.data.decode("utf-8"),
+ )
+ self.assertNotIn(
+ "Nothing to list", resp.data.decode("utf-8"),
+ )
+ self.assertIn(
+ "Some entries below contain IP addresses,", resp.data.decode("utf-8"),
+ )
+ self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 10)
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 1)
+
+ # Generate more operations to confirm additional IP info isn't recorded
+ self.do_misc_database_operations(LoggingMode.ENABLED)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 10)
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 6)
+
+ # Clear IP Data
+ resp = self.client.post("/demo/strip_ip_addresses", follow_redirects=True)
+ self.assertEqual(resp.status_code, 200)
+ self.assertNotIn(
+ "This project has history disabled. New actions won't appear below. ",
+ resp.data.decode("utf-8"),
+ )
+ self.assertNotIn(
+ "The table below reflects actions recorded prior to disabling project history.",
+ resp.data.decode("utf-8"),
+ )
+ self.assertNotIn(
+ "Nothing to list", resp.data.decode("utf-8"),
+ )
+ self.assertNotIn(
+ "Some entries below contain IP addresses,", resp.data.decode("utf-8"),
+ )
+ self.assertEqual(resp.data.decode("utf-8").count("127.0.0.1"), 0)
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 16)
+
+ def test_logs_for_common_actions(self):
+ # adds a member to this project
+ resp = self.client.post(
+ "/demo/members/add", data={"name": "alexis"}, follow_redirects=True
+ )
+ self.assertEqual(resp.status_code, 200)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ "Person %s added" % em_surround("alexis"), resp.data.decode("utf-8")
+ )
+
+ # create a bill
+ resp = self.client.post(
+ "/demo/add",
+ data={
+ "date": "2011-08-10",
+ "what": "fromage à raclette",
+ "payer": 1,
+ "payed_for": [1],
+ "amount": "25",
+ },
+ follow_redirects=True,
+ )
+ self.assertEqual(resp.status_code, 200)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ "Bill %s added" % em_surround("25.0 for fromage à raclette"),
+ resp.data.decode("utf-8"),
+ )
+
+ # edit the bill
+ resp = self.client.post(
+ "/demo/edit/1",
+ data={
+ "date": "2011-08-10",
+ "what": "new thing",
+ "payer": 1,
+ "payed_for": [1],
+ "amount": "10",
+ },
+ follow_redirects=True,
+ )
+ self.assertEqual(resp.status_code, 200)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ "Bill %s added" % em_surround("25.0 for fromage à raclette"),
+ resp.data.decode("utf-8"),
+ )
+ self.assertRegex(
+ resp.data.decode("utf-8"),
+ r"Bill %s:\s* Amount changed\s* from %s\s* to %s"
+ % (
+ em_surround("25.0 for fromage à raclette", regex_escape=True),
+ em_surround("25.0", regex_escape=True),
+ em_surround("10.0", regex_escape=True),
+ ),
+ )
+ self.assertIn(
+ "Bill %s renamed to %s"
+ % (em_surround("25.0 for fromage à raclette"), em_surround("new thing"),),
+ resp.data.decode("utf-8"),
+ )
+ self.assertLess(
+ resp.data.decode("utf-8").index(
+ "Bill %s renamed to" % em_surround("25.0 for fromage à raclette")
+ ),
+ resp.data.decode("utf-8").index("Amount changed"),
+ )
+
+ # delete the bill
+ resp = self.client.get("/demo/delete/1", follow_redirects=True)
+ self.assertEqual(resp.status_code, 200)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ "Bill %s removed" % em_surround("10.0 for new thing"),
+ resp.data.decode("utf-8"),
+ )
+
+ # edit user
+ resp = self.client.post(
+ "/demo/members/1/edit",
+ data={"weight": 2, "name": "new name"},
+ follow_redirects=True,
+ )
+ self.assertEqual(resp.status_code, 200)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertRegex(
+ resp.data.decode("utf-8"),
+ r"Person %s:\s* Weight changed\s* from %s\s* to %s"
+ % (
+ em_surround("alexis", regex_escape=True),
+ em_surround("1.0", regex_escape=True),
+ em_surround("2.0", regex_escape=True),
+ ),
+ )
+ self.assertIn(
+ "Person %s renamed to %s"
+ % (em_surround("alexis"), em_surround("new name"),),
+ resp.data.decode("utf-8"),
+ )
+ self.assertLess(
+ resp.data.decode("utf-8").index(
+ "Person %s renamed" % em_surround("alexis")
+ ),
+ resp.data.decode("utf-8").index("Weight changed"),
+ )
+
+ # delete user using POST method
+ resp = self.client.post("/demo/members/1/delete", follow_redirects=True)
+ self.assertEqual(resp.status_code, 200)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn(
+ "Person %s removed" % em_surround("new name"), resp.data.decode("utf-8")
+ )
+
+ def test_double_bill_double_person_edit_second(self):
+
+ # add two members
+ self.client.post("/demo/members/add", data={"name": "User 1"})
+ self.client.post("/demo/members/add", data={"name": "User 2"})
+
+ # add two bills
+ self.client.post(
+ "/demo/add",
+ data={
+ "date": "2020-04-13",
+ "what": "Bill 1",
+ "payer": 1,
+ "payed_for": [1, 2],
+ "amount": "25",
+ },
+ )
+ self.client.post(
+ "/demo/add",
+ data={
+ "date": "2020-04-13",
+ "what": "Bill 2",
+ "payer": 1,
+ "payed_for": [1, 2],
+ "amount": "20",
+ },
+ )
+
+ # Should be 5 history entries at this point
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 5)
+ self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+
+ # Edit ONLY the amount on the first bill
+ self.client.post(
+ "/demo/edit/1",
+ data={
+ "date": "2020-04-13",
+ "what": "Bill 1",
+ "payer": 1,
+ "payed_for": [1, 2],
+ "amount": "88",
+ },
+ )
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertRegex(
+ resp.data.decode("utf-8"),
+ r"Bill %s:\s* Amount changed\s* from %s\s* to %s"
+ % (
+ em_surround("25.0 for Bill 1", regex_escape=True),
+ em_surround("25.0", regex_escape=True),
+ em_surround("88.0", regex_escape=True),
+ ),
+ )
+
+ self.assertNotRegex(
+ resp.data.decode("utf-8"),
+ r"Removed\s* %s\s* and\s* %s\s* from\s* owers list"
+ % (
+ em_surround("User 1", regex_escape=True),
+ em_surround("User 2", regex_escape=True),
+ ),
+ resp.data.decode("utf-8"),
+ )
+
+ # Should be 6 history entries at this point
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 6)
+ self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+
+ def test_bill_add_remove_add(self):
+ # add two members
+ self.client.post("/demo/members/add", data={"name": "User 1"})
+ self.client.post("/demo/members/add", data={"name": "User 2"})
+
+ # add 1 bill
+ self.client.post(
+ "/demo/add",
+ data={
+ "date": "2020-04-13",
+ "what": "Bill 1",
+ "payer": 1,
+ "payed_for": [1, 2],
+ "amount": "25",
+ },
+ )
+
+ # delete the bill
+ self.client.get("/demo/delete/1", follow_redirects=True)
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 5)
+ self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+ self.assertIn(
+ "Bill %s added" % em_surround("25.0 for Bill 1"), resp.data.decode("utf-8")
+ )
+ self.assertIn(
+ "Bill %s removed" % em_surround("25.0 for Bill 1"),
+ resp.data.decode("utf-8"),
+ )
+
+ # Add a new bill
+ self.client.post(
+ "/demo/add",
+ data={
+ "date": "2020-04-13",
+ "what": "Bill 2",
+ "payer": 1,
+ "payed_for": [1, 2],
+ "amount": "20",
+ },
+ )
+
+ resp = self.client.get("/demo/history")
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 6)
+ self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
+ self.assertIn(
+ "Bill %s added" % em_surround("25.0 for Bill 1"), resp.data.decode("utf-8")
+ )
+ self.assertEqual(
+ resp.data.decode("utf-8").count(
+ "Bill %s added" % em_surround("25.0 for Bill 1")
+ ),
+ 1,
+ )
+ self.assertIn(
+ "Bill %s added" % em_surround("20.0 for Bill 2"), resp.data.decode("utf-8")
+ )
+ self.assertIn(
+ "Bill %s removed" % em_surround("25.0 for Bill 1"),
+ resp.data.decode("utf-8"),
+ )
+
+ def test_double_bill_double_person_edit_second_no_web(self):
+ u1 = models.Person(project_id="demo", name="User 1")
+ u2 = models.Person(project_id="demo", name="User 1")
+
+ models.db.session.add(u1)
+ models.db.session.add(u2)
+ models.db.session.commit()
+
+ b1 = models.Bill(what="Bill 1", payer_id=u1.id, owers=[u2], amount=10,)
+ b2 = models.Bill(what="Bill 2", payer_id=u2.id, owers=[u2], amount=11,)
+
+ # This db commit exposes the "spurious owers edit" bug
+ models.db.session.add(b1)
+ models.db.session.commit()
+
+ models.db.session.add(b2)
+ models.db.session.commit()
+
+ history_list = history.get_history(models.Project.query.get("demo"))
+ self.assertEqual(len(history_list), 5)
+
+ # Change just the amount
+ b1.amount = 5
+ models.db.session.commit()
+
+ history_list = history.get_history(models.Project.query.get("demo"))
+ for entry in history_list:
+ if "prop_changed" in entry:
+ self.assertNotIn("owers", entry["prop_changed"])
+ self.assertEqual(len(history_list), 6)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/ihatemoney/utils.py b/ihatemoney/utils.py
index 126b9de..0641d1c 100644
--- a/ihatemoney/utils.py
+++ b/ihatemoney/utils.py
@@ -2,6 +2,7 @@ import re
import os
import ast
import operator
+from enum import Enum
from io import BytesIO, StringIO
@@ -12,7 +13,6 @@ from babel import Locale
from werkzeug.routing import HTTPException, RoutingException
from datetime import datetime, timedelta
-
import csv
@@ -257,3 +257,26 @@ def same_bill(bill1, bill2):
if bill1[a] != bill2[a]:
return False
return True
+
+
+class FormEnum(Enum):
+ """Extend builtin Enum class to be seamlessly compatible with WTForms"""
+
+ @classmethod
+ def choices(cls):
+ return [(choice, choice.name) for choice in cls]
+
+ @classmethod
+ def coerce(cls, item):
+ """Coerce a str or int representation into an Enum object"""
+ if isinstance(item, cls):
+ return item
+
+ # If item is not already a Enum object then it must be
+ # a string or int corresponding to an ID (e.g. '0' or 1)
+ # Either int() or cls() will correctly throw a TypeError if this
+ # is not the case
+ return cls(int(item))
+
+ def __str__(self):
+ return str(self.value)
diff --git a/ihatemoney/versioning.py b/ihatemoney/versioning.py
new file mode 100644
index 0000000..50ad6ec
--- /dev/null
+++ b/ihatemoney/versioning.py
@@ -0,0 +1,94 @@
+from flask import g
+from sqlalchemy.orm.attributes import get_history
+from sqlalchemy_continuum import VersioningManager
+from sqlalchemy_continuum.plugins.flask import fetch_remote_addr
+
+from ihatemoney.utils import FormEnum
+
+
+class LoggingMode(FormEnum):
+ """Represents a project's history preferences."""
+
+ DISABLED = 0
+ ENABLED = 1
+ RECORD_IP = 2
+
+ @classmethod
+ def default(cls):
+ return cls.ENABLED
+
+
+class ConditionalVersioningManager(VersioningManager):
+ """Conditionally enable version tracking based on the given predicate."""
+
+ def __init__(self, tracking_predicate, *args, **kwargs):
+ """Create version entry iff tracking_predicate() returns True."""
+ super().__init__(*args, **kwargs)
+ self.tracking_predicate = tracking_predicate
+
+ def before_flush(self, session, flush_context, instances):
+ if self.tracking_predicate():
+ return super().before_flush(session, flush_context, instances)
+ else:
+ # At least one call to unit_of_work() needs to be made against the
+ # session object to prevent a KeyError later. This doesn't create
+ # a version or transaction entry
+ self.unit_of_work(session)
+
+ def after_flush(self, session, flush_context):
+ if self.tracking_predicate():
+ return super().after_flush(session, flush_context)
+ else:
+ # At least one call to unit_of_work() needs to be made against the
+ # session object to prevent a KeyError later. This doesn't create
+ # a version or transaction entry
+ self.unit_of_work(session)
+
+
+def version_privacy_predicate():
+ """Evaluate if the project of the current session has enabled logging."""
+ logging_enabled = False
+ try:
+ if g.project.logging_preference != LoggingMode.DISABLED:
+ logging_enabled = True
+
+ # If logging WAS enabled prior to this transaction,
+ # we log this one last transaction
+ old_logging_mode = get_history(g.project, "logging_preference")[2]
+ if old_logging_mode and old_logging_mode[0] != LoggingMode.DISABLED:
+ logging_enabled = True
+ except AttributeError:
+ # g.project doesn't exist, it's being created or this action is outside
+ # the scope of a project. Use the default logging mode to decide
+ if LoggingMode.default() != LoggingMode.DISABLED:
+ logging_enabled = True
+ return logging_enabled
+
+
+def get_ip_if_allowed():
+ """
+ Get the remote address (IP address) of the current Flask context, if the
+ project's privacy settings allow it. Behind the scenes, this calls back to
+ the FlaskPlugin from SQLAlchemy-Continuum in order to maintain forward
+ compatibility
+ """
+ ip_logging_allowed = False
+ try:
+ if g.project.logging_preference == LoggingMode.RECORD_IP:
+ ip_logging_allowed = True
+
+ # If ip recording WAS enabled prior to this transaction,
+ # we record the IP for this one last transaction
+ old_logging_mode = get_history(g.project, "logging_preference")[2]
+ if old_logging_mode and old_logging_mode[0] == LoggingMode.RECORD_IP:
+ ip_logging_allowed = True
+ except AttributeError:
+ # g.project doesn't exist, it's being created or this action is outside
+ # the scope of a project. Use the default logging mode to decide
+ if LoggingMode.default() == LoggingMode.RECORD_IP:
+ ip_logging_allowed = True
+
+ if ip_logging_allowed:
+ return fetch_remote_addr()
+ else:
+ return None
diff --git a/ihatemoney/web.py b/ihatemoney/web.py
index 8e0bca6..744d1bf 100644
--- a/ihatemoney/web.py
+++ b/ihatemoney/web.py
@@ -31,6 +31,7 @@ from flask import (
from flask_babel import get_locale, gettext as _
from flask_mail import Message
from sqlalchemy import orm
+from sqlalchemy_continuum import Operation
from werkzeug.exceptions import NotFound
from werkzeug.security import check_password_hash, generate_password_hash
@@ -46,7 +47,8 @@ from ihatemoney.forms import (
get_billform_for,
UploadForm,
)
-from ihatemoney.models import db, Project, Person, Bill
+from ihatemoney.history import get_history_queries, get_history
+from ihatemoney.models import db, Project, Person, Bill, LoggingMode
from ihatemoney.utils import (
Redirect303,
list_of_dicts2json,
@@ -404,6 +406,12 @@ def edit_project():
return redirect(url_for("main.list_bills"))
else:
edit_form.name.data = g.project.name
+
+ if g.project.logging_preference != LoggingMode.DISABLED:
+ edit_form.project_history.data = True
+ if g.project.logging_preference == LoggingMode.RECORD_IP:
+ edit_form.ip_recording.data = True
+
edit_form.contact_email.data = g.project.contact_email
return render_template(
@@ -742,6 +750,45 @@ def settle_bill():
return render_template("settle_bills.html", bills=bills, current_view="settle_bill")
+@main.route("/<project_id>/history")
+def history():
+ """Query for the version entries associated with this project."""
+ history = get_history(g.project, human_readable_names=True)
+
+ any_ip_addresses = any(event["ip"] for event in history)
+
+ return render_template(
+ "history.html",
+ current_view="history",
+ history=history,
+ any_ip_addresses=any_ip_addresses,
+ LoggingMode=LoggingMode,
+ OperationType=Operation,
+ current_log_pref=g.project.logging_preference,
+ )
+
+
+@main.route("/<project_id>/erase_history", methods=["POST"])
+def erase_history():
+ """Erase all history entries associated with this project."""
+ for query in get_history_queries(g.project):
+ query.delete(synchronize_session="fetch")
+
+ db.session.commit()
+ return redirect(url_for(".history"))
+
+
+@main.route("/<project_id>/strip_ip_addresses", methods=["POST"])
+def strip_ip_addresses():
+ """Strip ip addresses from history entries associated with this project."""
+ for query in get_history_queries(g.project):
+ for version_object in query.all():
+ version_object.transaction.remote_addr = None
+
+ db.session.commit()
+ return redirect(url_for(".history"))
+
+
@main.route("/<project_id>/statistics")
def statistics():
"""Compute what each member has paid and spent and display it"""