diff options
| -rw-r--r-- | ihatemoney/forms.py | 35 | ||||
| -rw-r--r-- | ihatemoney/history.py | 139 | ||||
| -rw-r--r-- | ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py | 214 | ||||
| -rw-r--r-- | ihatemoney/migrations/versions/cb038f79982e_sqlite_autoincrement.py | 50 | ||||
| -rw-r--r-- | ihatemoney/models.py | 74 | ||||
| -rw-r--r-- | ihatemoney/patch_sqlalchemy_continuum.py | 138 | ||||
| -rw-r--r-- | ihatemoney/static/css/main.css | 44 | ||||
| -rw-r--r-- | ihatemoney/static/images/add.png | bin | 0 -> 264 bytes | |||
| -rw-r--r-- | ihatemoney/static/images/x.svg | 8 | ||||
| -rw-r--r-- | ihatemoney/static/js/ihatemoney.js | 4 | ||||
| -rw-r--r-- | ihatemoney/templates/forms.html | 18 | ||||
| -rw-r--r-- | ihatemoney/templates/history.html | 250 | ||||
| -rw-r--r-- | ihatemoney/templates/layout.html | 1 | ||||
| -rw-r--r-- | ihatemoney/tests/tests.py | 648 | ||||
| -rw-r--r-- | ihatemoney/utils.py | 25 | ||||
| -rw-r--r-- | ihatemoney/versioning.py | 94 | ||||
| -rw-r--r-- | ihatemoney/web.py | 49 | ||||
| -rw-r--r-- | requirements.txt | 1 | ||||
| -rw-r--r-- | setup.cfg | 1 |
19 files changed, 1783 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 Binary files differnew file mode 100644 index 0000000..262891b --- /dev/null +++ b/ihatemoney/static/images/add.png 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">×</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">×</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""" diff --git a/requirements.txt b/requirements.txt index 2728042..d6893e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,5 +23,6 @@ MarkupSafe==1.1.1 python-dateutil==2.8.0 pytz==2019.2 SQLAlchemy==1.3.8 +SQLAlchemy-Continuum==1.3.9 Werkzeug==0.16.0 WTForms==2.2.1 @@ -25,6 +25,7 @@ install_requires = flask flask-wtf flask-sqlalchemy<3.0 + SQLAlchemy-Continuum flask-mail Flask-Migrate Flask-script |
