From edaab5501b9ac4c2c179d1258d4546f0a9520af4 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Fri, 10 Apr 2020 21:50:37 -0400 Subject: [PATCH 01/31] Add logging preferences --- ihatemoney/forms.py | 10 ++++++- ihatemoney/models.py | 3 +++ ihatemoney/static/js/ihatemoney.js | 43 ++++++++++++++++++++++++++++++ ihatemoney/templates/forms.html | 20 ++++++++++++++ ihatemoney/utils.py | 34 +++++++++++++++++++++++ ihatemoney/web.py | 1 + 6 files changed, 110 insertions(+), 1 deletion(-) diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 88afd2968..9d17cb409 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -23,7 +23,7 @@ import email_validator from ihatemoney.models import Project, Person -from ihatemoney.utils import slugify, eval_arithmetic_expression +from ihatemoney.utils import slugify, eval_arithmetic_expression, LoggingMode def strip_filter(string): @@ -89,6 +89,12 @@ class EditProjectForm(FlaskForm): name = StringField(_("Project name"), validators=[DataRequired()]) password = StringField(_("Private code"), validators=[DataRequired()]) contact_email = StringField(_("Email"), validators=[DataRequired(), Email()]) + logging_preferences = SelectField( + _("Logging Preferences"), + choices=LoggingMode.choices(), + coerce=LoggingMode.coerce, + default=LoggingMode.default(), + ) def save(self): """Create a new project with the information given by this form. @@ -100,6 +106,7 @@ def save(self): id=self.id.data, password=generate_password_hash(self.password.data), contact_email=self.contact_email.data, + logging_preference=self.logging_preferences.data, ) return project @@ -108,6 +115,7 @@ def update(self, project): project.name = self.name.data project.password = generate_password_hash(self.password.data) project.contact_email = self.contact_email.data + project.logging_preference = self.logging_preferences.data return project diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 4d32fd979..8a823f2e0 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -7,6 +7,7 @@ from debts import settle from sqlalchemy import orm from sqlalchemy.sql import func +from ihatemoney.utils import LoggingMode from itsdangerous import ( TimedJSONWebSignatureSerializer, URLSafeSerializer, @@ -27,6 +28,7 @@ def get_by_name(self, name): 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()) members = db.relationship("Person", backref="project") query_class = ProjectQuery @@ -37,6 +39,7 @@ def _to_serialize(self): "id": self.id, "name": self.name, "contact_email": self.contact_email, + "logging_preference": self.logging_preference.value, "members": [], } diff --git a/ihatemoney/static/js/ihatemoney.js b/ihatemoney/static/js/ihatemoney.js index c240dc1a8..8a7145dfc 100644 --- a/ihatemoney/static/js/ihatemoney.js +++ b/ihatemoney/static/js/ihatemoney.js @@ -5,3 +5,46 @@ function selectCheckboxes(value){ els[i].checked = value; } } + +function updateCheckBoxesFromPrivacySelect() { + var history_checkbox = document.getElementById('logging_enabled'); + var record_ip_checkbox = document.getElementById('record_ip'); + var record_ip_checkbox_text = document.getElementById("record_ip_label"); + var select_input = document.getElementById("logging_preferences"); + + if (select_input.selectedIndex === 0) { + history_checkbox.checked = false; + record_ip_checkbox.checked = false; + record_ip_checkbox.disabled = true; + record_ip_checkbox_text.classList.add("text-muted"); + } else if (select_input.selectedIndex === 1 || select_input.selectedIndex === 2) { + history_checkbox.checked = true; + record_ip_checkbox.disabled = false; + record_ip_checkbox_text.classList.remove("text-muted"); + if (select_input.selectedIndex === 2) { + record_ip_checkbox.checked = true + } + } +} + +function updatePrivacySelectFromCheckBoxes() { + var history_checkbox = document.getElementById('logging_enabled'); + var record_ip_checkbox = document.getElementById('record_ip'); + var record_ip_checkbox_text = document.getElementById("record_ip_label"); + var select_input = document.getElementById("logging_preferences"); + + if (!history_checkbox.checked) { + record_ip_checkbox.checked = false; + record_ip_checkbox.disabled = true; + record_ip_checkbox_text.classList.add("text-muted"); + select_input.selectedIndex = 0 + } else { + record_ip_checkbox.disabled = false; + record_ip_checkbox_text.classList.remove("text-muted"); + if (record_ip_checkbox.checked){ + select_input.selectedIndex = 2 + } else { + select_input.selectedIndex = 1 + } + } +} \ No newline at end of file diff --git a/ihatemoney/templates/forms.html b/ihatemoney/templates/forms.html index bec70184e..7401a80b0 100644 --- a/ihatemoney/templates/forms.html +++ b/ihatemoney/templates/forms.html @@ -78,6 +78,26 @@ {{ input(form.name) }} {{ input(form.password) }} {{ input(form.contact_email) }} +
+ +
+
+ + +
+
+
+ + +
+
+
+
+ {{ form.logging_preferences }} + +
+
+
{{ _("delete") }} diff --git a/ihatemoney/utils.py b/ihatemoney/utils.py index 126b9dee0..ad2a40b86 100644 --- a/ihatemoney/utils.py +++ b/ihatemoney/utils.py @@ -2,6 +2,7 @@ import os import ast import operator +from enum import Enum from io import BytesIO, StringIO @@ -257,3 +258,36 @@ 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) + + +class LoggingMode(FormEnum): + DISABLED = 0 + ENABLED = 1 + RECORD_IP = 2 + + @classmethod + def default(cls): + return cls.ENABLED diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 1b80ab622..55b4fd65b 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -391,6 +391,7 @@ def edit_project(): return redirect(url_for(".list_bills")) else: edit_form.name.data = g.project.name + edit_form.logging_preferences.data = g.project.logging_preference edit_form.contact_email.data = g.project.contact_email return render_template( From 5ebcc8bd512d0ffa457f910f71bbeda0e12739a7 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sat, 11 Apr 2020 13:46:55 -0400 Subject: [PATCH 02/31] Integrate SQLAlchemy-Continuum to support version tracking --- .../versions/2dcb0c0048dc_autologger.py | 212 ++++++++++++++++++ ihatemoney/models.py | 71 +++++- ihatemoney/utils.py | 17 +- 3 files changed, 296 insertions(+), 4 deletions(-) create mode 100644 ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py diff --git a/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py b/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py new file mode 100644 index 000000000..f17025ecb --- /dev/null +++ b/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py @@ -0,0 +1,212 @@ +"""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"), + 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"), + nullable=True, + ), + ) + # ### 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/models.py b/ihatemoney/models.py index 8a823f2e0..006210913 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -1,19 +1,72 @@ from collections import defaultdict from datetime import datetime + +import sqlalchemy from flask_sqlalchemy import SQLAlchemy, BaseQuery from flask import g, current_app from debts import settle from sqlalchemy import orm from sqlalchemy.sql import func -from ihatemoney.utils import LoggingMode +from ihatemoney.utils import LoggingMode, get_ip_if_allowed from itsdangerous import ( TimedJSONWebSignatureSerializer, URLSafeSerializer, BadSignature, SignatureExpired, ) +from sqlalchemy_continuum import make_versioned +from sqlalchemy_continuum import VersioningManager as VersioningManager +from sqlalchemy_continuum.plugins import FlaskPlugin + + +def version_privacy_predicate(): + """Evaluate if the project of the current session has enabled logging.""" + return g.project.logging_preference != LoggingMode.DISABLED + + +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) + + +make_versioned( + user_cls=None, + manager=ConditionalVersioningManager(tracking_predicate=version_privacy_predicate), + 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() @@ -23,6 +76,9 @@ class ProjectQuery(BaseQuery): 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) @@ -304,6 +360,9 @@ def get(self, id, project=None): query_class = PersonQuery + # Direct SQLAlchemy-Continuum to track changes to this model + __versioned__ = {} + 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") @@ -340,8 +399,8 @@ def __repr__(self): # 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), ) @@ -368,6 +427,9 @@ def delete(self, project, id): query_class = BillQuery + # Direct SQLAlchemy-Continuum to track changes to this model + __versioned__ = {} + id = db.Column(db.Integer, primary_key=True) payer_id = db.Column(db.Integer, db.ForeignKey("person.id")) @@ -429,3 +491,6 @@ def end_date(self): def __repr__(self): return "" + + +sqlalchemy.orm.configure_mappers() diff --git a/ihatemoney/utils.py b/ihatemoney/utils.py index ad2a40b86..9d6f79296 100644 --- a/ihatemoney/utils.py +++ b/ihatemoney/utils.py @@ -10,9 +10,10 @@ from json import dumps, JSONEncoder from flask import redirect, current_app from babel import Locale +from sqlalchemy_continuum.plugins.flask import fetch_remote_addr from werkzeug.routing import HTTPException, RoutingException from datetime import datetime, timedelta - +from flask import g import csv @@ -284,6 +285,8 @@ def __str__(self): class LoggingMode(FormEnum): + """Represents a project's history preferences.""" + DISABLED = 0 ENABLED = 1 RECORD_IP = 2 @@ -291,3 +294,15 @@ class LoggingMode(FormEnum): @classmethod def default(cls): return cls.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 + """ + if g.project and g.project.logging_preference == LoggingMode.RECORD_IP: + return fetch_remote_addr() + return None From 745c3fb8b56489747e0798d672f5210b23a4f5f5 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sun, 12 Apr 2020 20:53:45 -0400 Subject: [PATCH 03/31] Add moment.js w/ Jinja integration --- ihatemoney/momentjs.py | 21 +++++++++++++++++++++ ihatemoney/run.py | 2 ++ ihatemoney/static/js/moment.min.js | 1 + 3 files changed, 24 insertions(+) create mode 100644 ihatemoney/momentjs.py create mode 100644 ihatemoney/static/js/moment.min.js diff --git a/ihatemoney/momentjs.py b/ihatemoney/momentjs.py new file mode 100644 index 000000000..b5292805d --- /dev/null +++ b/ihatemoney/momentjs.py @@ -0,0 +1,21 @@ +from jinja2 import Markup + + +class momentjs(object): + def __init__(self, timestamp): + self.timestamp = timestamp + + def render(self, format): + return Markup( + '' + % (self.timestamp.strftime("%Y-%m-%dT%H:%M:%S Z"), format) + ) + + def format(self, fmt): + return self.render('format("%s")' % fmt) + + def calendar(self): + return self.render("calendar()") + + def fromNow(self): + return self.render("fromNow()") diff --git a/ihatemoney/run.py b/ihatemoney/run.py index 161761c09..a09872352 100644 --- a/ihatemoney/run.py +++ b/ihatemoney/run.py @@ -20,6 +20,7 @@ from ihatemoney.web import main as web_interface from ihatemoney import default_settings +from ihatemoney.momentjs import momentjs def setup_database(app): @@ -146,6 +147,7 @@ def create_app( app.jinja_env.globals["static_include"] = static_include app.jinja_env.globals["locale_from_iso"] = locale_from_iso app.jinja_env.filters["minimal_round"] = minimal_round + app.jinja_env.globals["momentjs"] = momentjs # Translations babel = Babel(app) diff --git a/ihatemoney/static/js/moment.min.js b/ihatemoney/static/js/moment.min.js new file mode 100644 index 000000000..5787a4085 --- /dev/null +++ b/ihatemoney/static/js/moment.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.moment=t()}(this,function(){"use strict";var e,i;function c(){return e.apply(null,arguments)}function o(e){return e instanceof Array||"[object Array]"===Object.prototype.toString.call(e)}function u(e){return null!=e&&"[object Object]"===Object.prototype.toString.call(e)}function l(e){return void 0===e}function h(e){return"number"==typeof e||"[object Number]"===Object.prototype.toString.call(e)}function d(e){return e instanceof Date||"[object Date]"===Object.prototype.toString.call(e)}function f(e,t){var n,s=[];for(n=0;n>>0,s=0;sSe(e)?(r=e+1,o-Se(e)):(r=e,o),{year:r,dayOfYear:a}}function Ie(e,t,n){var s,i,r=Ve(e.year(),t,n),a=Math.floor((e.dayOfYear()-r-1)/7)+1;return a<1?s=a+Ae(i=e.year()-1,t,n):a>Ae(e.year(),t,n)?(s=a-Ae(e.year(),t,n),i=e.year()+1):(i=e.year(),s=a),{week:s,year:i}}function Ae(e,t,n){var s=Ve(e,t,n),i=Ve(e+1,t,n);return(Se(e)-s+i)/7}I("w",["ww",2],"wo","week"),I("W",["WW",2],"Wo","isoWeek"),C("week","w"),C("isoWeek","W"),F("week",5),F("isoWeek",5),ue("w",B),ue("ww",B,z),ue("W",B),ue("WW",B,z),fe(["w","ww","W","WW"],function(e,t,n,s){t[s.substr(0,1)]=D(e)});function je(e,t){return e.slice(t,7).concat(e.slice(0,t))}I("d",0,"do","day"),I("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),I("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),I("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),I("e",0,0,"weekday"),I("E",0,0,"isoWeekday"),C("day","d"),C("weekday","e"),C("isoWeekday","E"),F("day",11),F("weekday",11),F("isoWeekday",11),ue("d",B),ue("e",B),ue("E",B),ue("dd",function(e,t){return t.weekdaysMinRegex(e)}),ue("ddd",function(e,t){return t.weekdaysShortRegex(e)}),ue("dddd",function(e,t){return t.weekdaysRegex(e)}),fe(["dd","ddd","dddd"],function(e,t,n,s){var i=n._locale.weekdaysParse(e,s,n._strict);null!=i?t.d=i:g(n).invalidWeekday=e}),fe(["d","e","E"],function(e,t,n,s){t[s]=D(e)});var Ze="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_");var ze="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_");var $e="Su_Mo_Tu_We_Th_Fr_Sa".split("_");var qe=ae;var Je=ae;var Be=ae;function Qe(){function e(e,t){return t.length-e.length}var t,n,s,i,r,a=[],o=[],u=[],l=[];for(t=0;t<7;t++)n=y([2e3,1]).day(t),s=this.weekdaysMin(n,""),i=this.weekdaysShort(n,""),r=this.weekdays(n,""),a.push(s),o.push(i),u.push(r),l.push(s),l.push(i),l.push(r);for(a.sort(e),o.sort(e),u.sort(e),l.sort(e),t=0;t<7;t++)o[t]=he(o[t]),u[t]=he(u[t]),l[t]=he(l[t]);this._weekdaysRegex=new RegExp("^("+l.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+u.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+o.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+a.join("|")+")","i")}function Xe(){return this.hours()%12||12}function Ke(e,t){I(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function et(e,t){return t._meridiemParse}I("H",["HH",2],0,"hour"),I("h",["hh",2],0,Xe),I("k",["kk",2],0,function(){return this.hours()||24}),I("hmm",0,0,function(){return""+Xe.apply(this)+L(this.minutes(),2)}),I("hmmss",0,0,function(){return""+Xe.apply(this)+L(this.minutes(),2)+L(this.seconds(),2)}),I("Hmm",0,0,function(){return""+this.hours()+L(this.minutes(),2)}),I("Hmmss",0,0,function(){return""+this.hours()+L(this.minutes(),2)+L(this.seconds(),2)}),Ke("a",!0),Ke("A",!1),C("hour","h"),F("hour",13),ue("a",et),ue("A",et),ue("H",B),ue("h",B),ue("k",B),ue("HH",B,z),ue("hh",B,z),ue("kk",B,z),ue("hmm",Q),ue("hmmss",X),ue("Hmm",Q),ue("Hmmss",X),ce(["H","HH"],ge),ce(["k","kk"],function(e,t,n){var s=D(e);t[ge]=24===s?0:s}),ce(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),ce(["h","hh"],function(e,t,n){t[ge]=D(e),g(n).bigHour=!0}),ce("hmm",function(e,t,n){var s=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s)),g(n).bigHour=!0}),ce("hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s,2)),t[pe]=D(e.substr(i)),g(n).bigHour=!0}),ce("Hmm",function(e,t,n){var s=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s))}),ce("Hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s,2)),t[pe]=D(e.substr(i))});var tt,nt=Te("Hours",!0),st={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:Ce,monthsShort:He,week:{dow:0,doy:6},weekdays:Ze,weekdaysMin:$e,weekdaysShort:ze,meridiemParse:/[ap]\.?m?\.?/i},it={},rt={};function at(e){return e?e.toLowerCase().replace("_","-"):e}function ot(e){var t=null;if(!it[e]&&"undefined"!=typeof module&&module&&module.exports)try{t=tt._abbr,require("./locale/"+e),ut(t)}catch(e){}return it[e]}function ut(e,t){var n;return e&&((n=l(t)?ht(e):lt(e,t))?tt=n:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),tt._abbr}function lt(e,t){if(null===t)return delete it[e],null;var n,s=st;if(t.abbr=e,null!=it[e])T("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),s=it[e]._config;else if(null!=t.parentLocale)if(null!=it[t.parentLocale])s=it[t.parentLocale]._config;else{if(null==(n=ot(t.parentLocale)))return rt[t.parentLocale]||(rt[t.parentLocale]=[]),rt[t.parentLocale].push({name:e,config:t}),null;s=n._config}return it[e]=new P(x(s,t)),rt[e]&&rt[e].forEach(function(e){lt(e.name,e.config)}),ut(e),it[e]}function ht(e){var t;if(e&&e._locale&&e._locale._abbr&&(e=e._locale._abbr),!e)return tt;if(!o(e)){if(t=ot(e))return t;e=[e]}return function(e){for(var t,n,s,i,r=0;r=t&&a(i,n,!0)>=t-1)break;t--}r++}return tt}(e)}function dt(e){var t,n=e._a;return n&&-2===g(e).overflow&&(t=n[_e]<0||11Pe(n[me],n[_e])?ye:n[ge]<0||24Ae(n,r,a)?g(e)._overflowWeeks=!0:null!=u?g(e)._overflowWeekday=!0:(o=Ee(n,s,i,r,a),e._a[me]=o.year,e._dayOfYear=o.dayOfYear)}(e),null!=e._dayOfYear&&(r=ct(e._a[me],s[me]),(e._dayOfYear>Se(r)||0===e._dayOfYear)&&(g(e)._overflowDayOfYear=!0),n=Ge(r,0,e._dayOfYear),e._a[_e]=n.getUTCMonth(),e._a[ye]=n.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=a[t]=s[t];for(;t<7;t++)e._a[t]=a[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[ge]&&0===e._a[ve]&&0===e._a[pe]&&0===e._a[we]&&(e._nextDay=!0,e._a[ge]=0),e._d=(e._useUTC?Ge:function(e,t,n,s,i,r,a){var o;return e<100&&0<=e?(o=new Date(e+400,t,n,s,i,r,a),isFinite(o.getFullYear())&&o.setFullYear(e)):o=new Date(e,t,n,s,i,r,a),o}).apply(null,a),i=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[ge]=24),e._w&&void 0!==e._w.d&&e._w.d!==i&&(g(e).weekdayMismatch=!0)}}var mt=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,_t=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,yt=/Z|[+-]\d\d(?::?\d\d)?/,gt=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],vt=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],pt=/^\/?Date\((\-?\d+)/i;function wt(e){var t,n,s,i,r,a,o=e._i,u=mt.exec(o)||_t.exec(o);if(u){for(g(e).iso=!0,t=0,n=gt.length;tn.valueOf():n.valueOf()this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},mn.isLocal=function(){return!!this.isValid()&&!this._isUTC},mn.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},mn.isUtc=Et,mn.isUTC=Et,mn.zoneAbbr=function(){return this._isUTC?"UTC":""},mn.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},mn.dates=n("dates accessor is deprecated. Use date instead.",un),mn.months=n("months accessor is deprecated. Use month instead",Ue),mn.years=n("years accessor is deprecated. Use year instead",Oe),mn.zone=n("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?("string"!=typeof e&&(e=-e),this.utcOffset(e,t),this):-this.utcOffset()}),mn.isDSTShifted=n("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!l(this._isDSTShifted))return this._isDSTShifted;var e={};if(w(e,this),(e=Ot(e))._a){var t=e._isUTC?y(e._a):bt(e._a);this._isDSTShifted=this.isValid()&&0 Date: Sun, 12 Apr 2020 20:58:20 -0400 Subject: [PATCH 04/31] Only update password if changed to prevent spurious log entries --- ihatemoney/forms.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 9d17cb409..f3aa6162d 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -14,7 +14,7 @@ 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 @@ -113,7 +113,11 @@ def save(self): 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_preferences.data From ade3e26b72f28c41a1301824f598b490ac8c6876 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sun, 12 Apr 2020 21:01:06 -0400 Subject: [PATCH 05/31] Add __str__ functions to all logged models --- ihatemoney/models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 006210913..25317d804 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -336,6 +336,9 @@ def verify_token(token, token_type="timed_token"): return None return data["project_id"] + def __str__(self): + return self.name + def __repr__(self): return "" % self.name @@ -468,6 +471,9 @@ def pay_each(self): else: return 0 + def __str__(self): + return "%s for %s" % (self.amount, self.what) + def __repr__(self): return "" % ( self.amount, From 9b3dfa506ccf775a9a0927aed0e3e5f466dc4352 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sun, 12 Apr 2020 21:03:40 -0400 Subject: [PATCH 06/31] Refactor versioning classes to avoid circular dependencies --- ihatemoney/forms.py | 4 +- ihatemoney/models.py | 44 +++++-------------- ihatemoney/utils.py | 26 ----------- ihatemoney/versioning.py | 94 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 61 deletions(-) create mode 100644 ihatemoney/versioning.py diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index f3aa6162d..9d4b188cb 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -22,8 +22,8 @@ import email_validator -from ihatemoney.models import Project, Person -from ihatemoney.utils import slugify, eval_arithmetic_expression, LoggingMode +from ihatemoney.models import Project, Person, LoggingMode +from ihatemoney.utils import slugify, eval_arithmetic_expression def strip_filter(string): diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 25317d804..a0475bdb3 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -9,7 +9,6 @@ from debts import settle from sqlalchemy import orm from sqlalchemy.sql import func -from ihatemoney.utils import LoggingMode, get_ip_if_allowed from itsdangerous import ( TimedJSONWebSignatureSerializer, URLSafeSerializer, @@ -17,40 +16,15 @@ SignatureExpired, ) from sqlalchemy_continuum import make_versioned -from sqlalchemy_continuum import VersioningManager as VersioningManager from sqlalchemy_continuum.plugins import FlaskPlugin +from sqlalchemy_continuum import version_class - -def version_privacy_predicate(): - """Evaluate if the project of the current session has enabled logging.""" - return g.project.logging_preference != LoggingMode.DISABLED - - -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) +from ihatemoney.versioning import ( + LoggingMode, + ConditionalVersioningManager, + version_privacy_predicate, + get_ip_if_allowed, +) make_versioned( @@ -500,3 +474,7 @@ def __repr__(self): sqlalchemy.orm.configure_mappers() + +PersonVersion = version_class(Person) +ProjectVersion = version_class(Project) +BillVersion = version_class(Bill) diff --git a/ihatemoney/utils.py b/ihatemoney/utils.py index 9d6f79296..0641d1c49 100644 --- a/ihatemoney/utils.py +++ b/ihatemoney/utils.py @@ -10,10 +10,8 @@ from json import dumps, JSONEncoder from flask import redirect, current_app from babel import Locale -from sqlalchemy_continuum.plugins.flask import fetch_remote_addr from werkzeug.routing import HTTPException, RoutingException from datetime import datetime, timedelta -from flask import g import csv @@ -282,27 +280,3 @@ def coerce(cls, item): def __str__(self): return str(self.value) - - -class LoggingMode(FormEnum): - """Represents a project's history preferences.""" - - DISABLED = 0 - ENABLED = 1 - RECORD_IP = 2 - - @classmethod - def default(cls): - return cls.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 - """ - if g.project and g.project.logging_preference == LoggingMode.RECORD_IP: - return fetch_remote_addr() - return None diff --git a/ihatemoney/versioning.py b/ihatemoney/versioning.py new file mode 100644 index 000000000..50ad6ec81 --- /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 From b9f5658aae1a583235e202bb0bd978cad350381f Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sun, 12 Apr 2020 21:04:43 -0400 Subject: [PATCH 07/31] Add functions to query project history --- ihatemoney/history.py | 139 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 ihatemoney/history.py diff --git a/ihatemoney/history.py b/ihatemoney/history.py new file mode 100644 index 000000000..edd7f84a7 --- /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, + "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) From c95e50aba5a76b3b18c42fc3931b6b17d73f2df4 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sun, 12 Apr 2020 21:05:23 -0400 Subject: [PATCH 08/31] Add history page and endpoints --- ihatemoney/static/css/main.css | 36 ++++- ihatemoney/static/images/add.png | Bin 0 -> 264 bytes ihatemoney/templates/history.html | 235 ++++++++++++++++++++++++++++++ ihatemoney/templates/layout.html | 2 + ihatemoney/web.py | 47 +++++- 5 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 ihatemoney/static/images/add.png create mode 100644 ihatemoney/templates/history.html diff --git a/ihatemoney/static/css/main.css b/ihatemoney/static/css/main.css index a646b1759..aa266e105 100644 --- a/ihatemoney/static/css/main.css +++ b/ihatemoney/static/css/main.css @@ -320,7 +320,7 @@ footer .footer-left { background: url("../images/see.png") no-repeat right; } -#bill_table, #monthly_stats { +#bill_table, #monthly_stats, #history_table { margin-top: 30px; margin-bottom: 30px; } @@ -356,6 +356,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; } @@ -516,3 +546,7 @@ footer .icon svg { text-align: right; width: 200px; } + +#history_warnings { + margin-top: 30px; +} diff --git a/ihatemoney/static/images/add.png b/ihatemoney/static/images/add.png new file mode 100644 index 0000000000000000000000000000000000000000..262891bf14eb8c2967317ad8694398745903f51c GIT binary patch literal 264 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Y)RhkE)4%caKYZ?lYt_f1s;*b zKpodXn9)gNb_Gz7y~NYkmHi0|w~&#r=$j2LK%uFgE{-73qGpx{W<)cpFi!#q6HF{{ event.object_desc }}{% endmacro %} + +{% macro simple_property_change(event, localized_property_name, from=True) %} + {{ describe_object(event) }}: + {{ localized_property_name }} {{ _("changed") }} + {% if from %}{{ _("from") }} {{ event.val_before }} {% endif %} + {{ _("to") }} {{ event.val_after }} +{% 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 %} + {{ name }}{% 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 %} + {{ event.val_after[0] }} + {% endif %} + {% if add %}{{ _("to") }}{% else %}{{ _("from") }}{% endif %} + {{ _("owers list") }} +{% endmacro %} + +{% block sidebar %} +
+ + + + + + + + {% set balance = g.project.balance %} + {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %} + + + + + {% endfor %} +
{{ _("Who?") }}{{ _("Balance") }}
{{ member.name }} + {% if balance[member.id]|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }} +
+
+{% endblock %} + + + +{% block content %} + {% if current_log_pref == LoggingMode.DISABLED or (current_log_pref != LoggingMode.RECORD_IP and any_ip_addresses) %} +
+ {% if current_log_pref == LoggingMode.DISABLED %} +

+ {{ _("This project has history disabled. New actions won't appear below. You can enable history on the") }} + {{ _("settings page") }} +

+ {% if history %} +

{{ _("The table below reflects actions recorded prior to disabling project history. You can ") }} + {{ _("clear project history") }} {{ _("to remove them.") }}

+ + + {% endif %} + {% endif %} + {% if current_log_pref != LoggingMode.RECORD_IP and any_ip_addresses %} +

{{ _("Some entries below contain IP addresses, even though this project has IP recording disabled. ") }} + {{ _(" Delete stored IP addresses ") }}

+ + + {% endif %} +
+ {% endif %} + {% if history %} + + + + + + + + {% for event in history %} + + + + + + {% endfor %} + +
{{ _("Time") }}{{ _("Event") }} + + {{ _("From IP") }}
{{ momentjs(event.time).calendar() }} +
+ +
+
+ {% if event.operation_type == OperationType.INSERT %} + {{ event.object_type }} {{ event.object_desc }} {{ _("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 ") }} {{ event.object_desc }} + {% elif event.prop_changed == "contact_email" %} + {{ _("Project contact email changed to ") }} {{ event.val_after }} + {% else %} + {{ _("Project settings modified") }} + {% endif %} + {% elif event.prop_changed == "activated" %} + {{ event.object_type }} {{ event.object_desc }} + {% if event.val_after == False %}{{ _("deactivated") }}{% else %}{{ _("reactivated") }}{% endif %} + {% elif event.prop_changed == "name" or event.prop_changed == "what" %} + {{ describe_object(event) }} {{ _("renamed") }} {{ _("to") }} {{ event.val_after }} + {% elif event.prop_changed == "weight" %} + {{ simple_property_change(event, _("Weight")) }} + {% elif event.prop_changed == "external_link" %} + {{ describe_object(event) }}: {{ _("External link changed to") }} + {{ event.val_after }} + {% 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 }} {{ event.object_desc }} {{ _("removed") }} + {% else %} + {# Should be unreachable #} + {{ describe_object(event) }} {{ _("changed in a unknown way") }} + {% endif %} +
+
{% if event.ip %}{{ event.ip }}{% else %} -- {% endif %}
+ {% else %} +
+
+
+ {{ static_include("images/hand-holding-heart.svg") | safe }} +

{{ _('Nothing to list')}}

+

+ {{ _("Someone probably") }}
+ {{ _("cleared the project history.") }} +

+
+
+ {% endif %} + +{% endblock %} diff --git a/ihatemoney/templates/layout.html b/ihatemoney/templates/layout.html index dc3d32f6f..449ab350b 100644 --- a/ihatemoney/templates/layout.html +++ b/ihatemoney/templates/layout.html @@ -12,6 +12,7 @@ + {%- if request.path == "/dashboard" %} @@ -45,6 +46,7 @@

#! mo + {% endblock %} diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 55b4fd65b..60cb84541 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -31,6 +31,7 @@ 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 @@ 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, @@ -741,6 +743,49 @@ def settle_bill(): return render_template("settle_bills.html", bills=bills, current_view="settle_bill") +@main.route("//history") +def history(): + """Query for the version entries associated with this project.""" + history = get_history(g.project, human_readable_names=True) + + any_ip_addresses = False + for event in history: + if event["ip"]: + any_ip_addresses = True + break + + 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("//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("//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("//statistics") def statistics(): """Compute what each member has paid and spent and display it""" From 0d4fe30d24dd77853ce7798f0867ee2747205c92 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sun, 12 Apr 2020 21:23:40 -0400 Subject: [PATCH 09/31] Add SQLAlchemy to dependencies --- requirements.txt | 1 + setup.cfg | 1 + 2 files changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index 27280428b..d6893e843 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 diff --git a/setup.cfg b/setup.cfg index b62a3f576..8d334e80a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,7 @@ install_requires = flask flask-wtf flask-sqlalchemy<3.0 + SQLAlchemy-Continuum flask-mail Flask-Migrate Flask-script From e6013a2c1179d7fc6d25c288b4459834ac22e999 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sun, 12 Apr 2020 21:42:52 -0400 Subject: [PATCH 10/31] Add logging_preference item to API expected output --- ihatemoney/tests/tests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index c4b1585cc..005a9091e 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -1517,6 +1517,7 @@ def test_project(self): "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) @@ -1544,6 +1545,7 @@ def test_project(self): "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,6 +2106,7 @@ def test_weighted_bills(self): "contact_email": "raclette@notmyidea.org", "id": "raclette", "name": "raclette", + "logging_preference": 1, } self.assertStatus(200, req) From bd30892865abc330c8bb90b204281af747ee83fc Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 13 Apr 2020 00:20:42 -0400 Subject: [PATCH 11/31] Whitespace tweaks in history.html --- ihatemoney/templates/history.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ihatemoney/templates/history.html b/ihatemoney/templates/history.html index 6f6a4de54..7349a93e7 100644 --- a/ihatemoney/templates/history.html +++ b/ihatemoney/templates/history.html @@ -19,11 +19,11 @@ {% endif %} {% elif event.val_after == LoggingMode.RECORD_IP %} {% if event.val_before == LoggingMode.DISABLED %} - {{ _("Enabled Project History & IP Address Recording") }} + {{ _("Enabled Project History & IP Address Recording") }} {% elif event.val_before == LoggingMode.ENABLED %} {{ _("Enabled IP Address Recording") }} {% else %} - {{ _("Enabled Project History & IP Address Recording") }} + {{ _("Enabled Project History & IP Address Recording") }} {% endif %} {% else %} {# Should be unreachable #} @@ -36,7 +36,7 @@ {% macro simple_property_change(event, localized_property_name, from=True) %} {{ describe_object(event) }}: {{ localized_property_name }} {{ _("changed") }} - {% if from %}{{ _("from") }} {{ event.val_before }} {% endif %} + {% if from %}{{ _("from") }} {{ event.val_before }}{% endif %} {{ _("to") }} {{ event.val_after }} {% endmacro %} @@ -176,9 +176,9 @@ {% elif event.prop_changed == "logging_preference" %} {{ change_to_logging_preference(event) }} {% elif event.prop_changed == "name" %} - {{ _("Project renamed to ") }} {{ event.object_desc }} + {{ _("Project renamed to") }} {{ event.val_after }} {% elif event.prop_changed == "contact_email" %} - {{ _("Project contact email changed to ") }} {{ event.val_after }} + {{ _("Project contact email changed to") }} {{ event.val_after }} {% else %} {{ _("Project settings modified") }} {% endif %} From 5d103745408a853b47c629681969f46085db8072 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 13 Apr 2020 00:24:27 -0400 Subject: [PATCH 12/31] Made logging_preference a required field in the project edit form --- ihatemoney/forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 9d4b188cb..89e7b919e 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -94,6 +94,7 @@ class EditProjectForm(FlaskForm): choices=LoggingMode.choices(), coerce=LoggingMode.coerce, default=LoggingMode.default(), + validators=[DataRequired()], ) def save(self): From 86872c93b2c478033c485770829562ad75f2a3f1 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 13 Apr 2020 00:25:03 -0400 Subject: [PATCH 13/31] Add versioning and history tests --- ihatemoney/tests/tests.py | 443 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 443 insertions(+) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index 005a9091e..fcb10913a 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -18,6 +18,7 @@ from ihatemoney.run import create_app, db, load_configuration from ihatemoney.manage import GenerateConfig, GeneratePasswordHash, DeleteProject from ihatemoney import models +from ihatemoney.versioning import LoggingMode from ihatemoney import utils from sqlalchemy import orm @@ -843,6 +844,7 @@ def test_edit_project(self): "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) @@ -2113,6 +2115,25 @@ def test_weighted_bills(self): 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.assertTrue(resp.data.decode("utf-8").count(" -- ") == 2) + self.assertNotIn("127.0.0.1", resp.data.decode("utf-8")) + class ServerTestCase(IhatemoneyTestCase): def test_homepage(self): @@ -2227,5 +2248,427 @@ def test_bill_pay_each(self): self.assertEqual(bill.pay_each(), pay_each_expected) +def em_surround(string): + return '%s' % 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.assertTrue(resp.data.decode("utf-8").count(" -- ") == 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", + "logging_preferences": logging_preference.value, + } + + # 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) + self.assertIn( + '' + % (logging_preference.value, logging_preference.name), + 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(" -- ", 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", + } + + 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(" -- "), 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( + '', 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(" -- "), 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(" -- "), 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(" -- "), 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(" -- "), 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(" -- "), 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(" -- "), 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", + "logging_preferences": logging_mode.value, + # Keep privacy settings where they were + } + + 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) + + # 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) + # edit the bill + resp = self.client.post( + "/demo/edit/1", + data={ + "date": "2011-08-10", + "what": "fromage à raclette", + "payer": 1, + "payed_for": [1], + "amount": "10", + }, + follow_redirects=True, + ) + self.assertEqual(resp.status_code, 200) + # delete the bill + resp = self.client.get("/demo/delete/1", follow_redirects=True) + self.assertEqual(resp.status_code, 200) + # delete user using POST method + resp = self.client.post("/demo/members/1/delete", 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(" -- "), 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(" -- "), 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(" -- "), 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.assertIn( + "Bill %s:\n Amount changed\n from %s\n to %s" + % ( + em_surround("25.0 for fromage à raclette"), + em_surround("25.0"), + em_surround("10.0"), + ), + resp.data.decode("utf-8"), + ) + 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.assertIn( + "Person %s:\n Weight changed\n from %s\n to %s" + % (em_surround("alexis"), em_surround("1.0"), em_surround("2.0")), + resp.data.decode("utf-8"), + ) + 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") + ) + + if __name__ == "__main__": unittest.main() From f16495a1e4e3a6c3ecdb08c121c8c1802624459f Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 13 Apr 2020 01:22:58 -0400 Subject: [PATCH 14/31] Change logging_preference to be NOT NULL --- ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py | 4 +++- ihatemoney/models.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py b/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py index f17025ecb..080083558 100644 --- a/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py +++ b/ihatemoney/migrations/versions/2dcb0c0048dc_autologger.py @@ -129,6 +129,7 @@ def upgrade(): sa.Column( "logging_preference", sa.Enum("DISABLED", "ENABLED", "RECORD_IP", name="loggingmode"), + server_default="ENABLED", autoincrement=False, nullable=True, ), @@ -169,7 +170,8 @@ def upgrade(): sa.Column( "logging_preference", sa.Enum("DISABLED", "ENABLED", "RECORD_IP", name="loggingmode"), - nullable=True, + server_default="ENABLED", + nullable=False, ), ) # ### end Alembic commands ### diff --git a/ihatemoney/models.py b/ihatemoney/models.py index a0475bdb3..a9e28e147 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -58,7 +58,12 @@ def get_by_name(self, name): 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()) + 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 From e9fa30f3d49e4ea134084610953b11f7cfddedbb Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 13 Apr 2020 16:26:19 -0400 Subject: [PATCH 15/31] Change some assertTrue's to assertEquals --- ihatemoney/tests/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index fcb10913a..149957f1c 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -2131,7 +2131,7 @@ def test_log_created_from_api_call(self): self.assertIn( "Project %s added" % em_surround("raclette"), resp.data.decode("utf-8"), ) - self.assertTrue(resp.data.decode("utf-8").count(" -- ") == 2) + self.assertEqual(resp.data.decode("utf-8").count(" -- "), 2) self.assertNotIn("127.0.0.1", resp.data.decode("utf-8")) @@ -2264,7 +2264,7 @@ def test_simple_create_logentry_no_ip(self): self.assertIn( "Project %s added" % em_surround("demo"), resp.data.decode("utf-8"), ) - self.assertTrue(resp.data.decode("utf-8").count(" -- ") == 1) + self.assertEqual(resp.data.decode("utf-8").count(" -- "), 1) self.assertNotIn("127.0.0.1", resp.data.decode("utf-8")) def change_privacy_to(self, logging_preference): From 32ad3d77758b751b1531752587113d00055ddab6 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 13 Apr 2020 16:44:46 -0400 Subject: [PATCH 16/31] Convert whitespace mess to assertRegex() calls --- ihatemoney/tests/tests.py | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index 149957f1c..c998d1b5c 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -2248,8 +2248,11 @@ def test_bill_pay_each(self): self.assertEqual(bill.pay_each(), pay_each_expected) -def em_surround(string): - return '%s' % string +def em_surround(string, regex_escape=False): + if regex_escape: + return '%s<\/em>' % string + else: + return '%s' % string class HistoryTestCase(IhatemoneyTestCase): @@ -2600,14 +2603,14 @@ def test_logs_for_common_actions(self): "Bill %s added" % em_surround("25.0 for fromage à raclette"), resp.data.decode("utf-8"), ) - self.assertIn( - "Bill %s:\n Amount changed\n from %s\n to %s" + self.assertRegex( + resp.data.decode("utf-8"), + "Bill %s:\s* Amount changed\s* from %s\s* to %s" % ( - em_surround("25.0 for fromage à raclette"), - em_surround("25.0"), - em_surround("10.0"), + em_surround("25.0 for fromage à raclette", regex_escape=True), + em_surround("25.0", regex_escape=True), + em_surround("10.0", regex_escape=True), ), - resp.data.decode("utf-8"), ) self.assertIn( "Bill %s renamed to %s" @@ -2642,10 +2645,14 @@ def test_logs_for_common_actions(self): resp = self.client.get("/demo/history") self.assertEqual(resp.status_code, 200) - self.assertIn( - "Person %s:\n Weight changed\n from %s\n to %s" - % (em_surround("alexis"), em_surround("1.0"), em_surround("2.0")), + self.assertRegex( resp.data.decode("utf-8"), + "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" From 931b3f84dae37775305e1d891ea63095ab96178f Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 13 Apr 2020 16:51:53 -0400 Subject: [PATCH 17/31] Added test cases exposing id duplication and owers change bugs --- ihatemoney/tests/tests.py | 137 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index c998d1b5c..d409152be 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -2676,6 +2676,143 @@ def test_logs_for_common_actions(self): "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(" -- "), 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"), + "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"), + "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(" -- "), 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(" -- "), 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(" -- "), 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.assertEquals( + 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"), + ) + if __name__ == "__main__": unittest.main() From d008e88f31ba8f21b58d25e1f0c1cae3da0ed509 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 13 Apr 2020 19:32:04 -0400 Subject: [PATCH 18/31] Remove hardcoded ids from history tests --- ihatemoney/tests/tests.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index d409152be..11fa05588 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -2428,37 +2428,45 @@ def do_misc_database_operations(self, logging_mode): ) 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": 1, - "payed_for": [1], + "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/1", + "/demo/edit/%i" % bill_id, data={ "date": "2011-08-10", "what": "fromage à raclette", - "payer": 1, - "payed_for": [1], + "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/1", follow_redirects=True) + 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/1/delete", follow_redirects=True) + 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): From e7848e39c656b130039cc5c62933ea9601a041bc Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 13 Apr 2020 19:32:54 -0400 Subject: [PATCH 19/31] Configure SQLite to disable ID reuse --- .../cb038f79982e_sqlite_autoincrement.py | 50 +++++++++++++++++++ ihatemoney/models.py | 5 ++ 2 files changed, 55 insertions(+) create mode 100644 ihatemoney/migrations/versions/cb038f79982e_sqlite_autoincrement.py diff --git a/ihatemoney/migrations/versions/cb038f79982e_sqlite_autoincrement.py b/ihatemoney/migrations/versions/cb038f79982e_sqlite_autoincrement.py new file mode 100644 index 000000000..ae5ab326a --- /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 a9e28e147..a9eb11970 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -345,6 +345,8 @@ def get(self, id, project=None): # 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") @@ -383,6 +385,7 @@ def __repr__(self): "billowers", db.Column("bill_id", db.Integer, db.ForeignKey("bill.id"), primary_key=True), db.Column("person_id", db.Integer, db.ForeignKey("person.id"), primary_key=True), + sqlite_autoincrement=True, ) @@ -412,6 +415,8 @@ def delete(self, project, id): # 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")) From 5cc8b5a68c4984f3028e1a145dfd5a0e524c5ef5 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Mon, 13 Apr 2020 19:35:52 -0400 Subject: [PATCH 20/31] Replace loop in web.py with generator expression Co-Authored-By: Glandos --- ihatemoney/web.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 5bb6c54a5..e1e0eb8b6 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -750,11 +750,7 @@ def history(): """Query for the version entries associated with this project.""" history = get_history(g.project, human_readable_names=True) - any_ip_addresses = False - for event in history: - if event["ip"]: - any_ip_addresses = True - break + any_ip_addresses = any(event["ip"] for event in history) return render_template( "history.html", From 825386ce997c2925c662552fa9263e377f64d20d Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Fri, 17 Apr 2020 14:45:19 -0400 Subject: [PATCH 21/31] Add test to verify unrelated-change owers bug without web requests --- ihatemoney/tests/tests.py | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index 11fa05588..eda6034df 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -17,7 +17,7 @@ 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 @@ -78,7 +78,7 @@ def create_project(self, name): class IhatemoneyTestCase(BaseTestCase): - SQLALCHEMY_DATABASE_URI = "sqlite://" + SQLALCHEMY_DATABASE_URI = "sqlite:////tmp/ihatemoneytest.db" TESTING = True WTF_CSRF_ENABLED = False # Simplifies the tests. @@ -2807,7 +2807,7 @@ def test_bill_add_remove_add(self): self.assertIn( "Bill %s added" % em_surround("25.0 for Bill 1"), resp.data.decode("utf-8") ) - self.assertEquals( + self.assertEqual( resp.data.decode("utf-8").count( "Bill %s added" % em_surround("25.0 for Bill 1") ), @@ -2821,6 +2821,37 @@ def test_bill_add_remove_add(self): 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() From e3e889e4805915f0d2d814cd2c2e43c69532c108 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Fri, 17 Apr 2020 15:40:17 -0400 Subject: [PATCH 22/31] Work-around to patch SQLAlchemy-Continuum 1.3.9 to fix the unrelated change causes change to owers bug --- ihatemoney/models.py | 10 +- ihatemoney/patch_sqlalchemy_continuum.py | 138 +++++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 ihatemoney/patch_sqlalchemy_continuum.py diff --git a/ihatemoney/models.py b/ihatemoney/models.py index a9eb11970..d765c93db 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -19,6 +19,7 @@ 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, @@ -29,7 +30,14 @@ make_versioned( user_cls=None, - manager=ConditionalVersioningManager(tracking_predicate=version_privacy_predicate), + 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 diff --git a/ihatemoney/patch_sqlalchemy_continuum.py b/ihatemoney/patch_sqlalchemy_continuum.py new file mode 100644 index 000000000..e0680c6a0 --- /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() From 24c38fa81f78eb658915157c20f7470453e056d5 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Fri, 17 Apr 2020 15:46:53 -0400 Subject: [PATCH 23/31] Mark RegEx strings as raw --- ihatemoney/tests/tests.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index eda6034df..96c61025f 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -2613,7 +2613,7 @@ def test_logs_for_common_actions(self): ) self.assertRegex( resp.data.decode("utf-8"), - "Bill %s:\s* Amount changed\s* from %s\s* to %s" + 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), @@ -2655,7 +2655,7 @@ def test_logs_for_common_actions(self): self.assertEqual(resp.status_code, 200) self.assertRegex( resp.data.decode("utf-8"), - "Person %s:\s* Weight changed\s* from %s\s* to %s" + 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), @@ -2734,7 +2734,7 @@ def test_double_bill_double_person_edit_second(self): self.assertEqual(resp.status_code, 200) self.assertRegex( resp.data.decode("utf-8"), - "Bill %s:\s* Amount changed\s* from %s\s* to %s" + 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), @@ -2744,7 +2744,7 @@ def test_double_bill_double_person_edit_second(self): self.assertNotRegex( resp.data.decode("utf-8"), - "Removed\s* %s\s* and\s* %s\s* from\s* owers list" + 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), From 9a875fd476076ea8dc88887e92c05a590d1537d9 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Fri, 17 Apr 2020 15:50:15 -0400 Subject: [PATCH 24/31] Revert Mistakenly Committed Debugging Change --- ihatemoney/tests/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index 96c61025f..6da061143 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -78,7 +78,7 @@ def create_project(self, name): class IhatemoneyTestCase(BaseTestCase): - SQLALCHEMY_DATABASE_URI = "sqlite:////tmp/ihatemoneytest.db" + SQLALCHEMY_DATABASE_URI = "sqlite://" TESTING = True WTF_CSRF_ENABLED = False # Simplifies the tests. From 114c151501ff70b0a697575763a2498d32532156 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Fri, 17 Apr 2020 15:53:28 -0400 Subject: [PATCH 25/31] Mark RegEx string as raw --- ihatemoney/tests/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index 6da061143..84b212a2c 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -2250,7 +2250,7 @@ def test_bill_pay_each(self): def em_surround(string, regex_escape=False): if regex_escape: - return '%s<\/em>' % string + return r'%s<\/em>' % string else: return '%s' % string From 0b206daab2f9fbf4359b434a745f681031f1d8fa Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Fri, 17 Apr 2020 16:16:32 -0400 Subject: [PATCH 26/31] Replace JavaScript-based dependent checkboxes with WTForms BooleanFields --- ihatemoney/forms.py | 40 ++++++++++++++++++++------- ihatemoney/static/js/ihatemoney.js | 43 ------------------------------ ihatemoney/templates/forms.html | 26 +++++++++--------- ihatemoney/tests/tests.py | 34 ++++++++++++++++------- ihatemoney/web.py | 7 ++++- 5 files changed, 73 insertions(+), 77 deletions(-) diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 79ab38bf9..aed0c97a6 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, @@ -89,25 +89,28 @@ class EditProjectForm(FlaskForm): name = StringField(_("Project name"), validators=[DataRequired()]) password = StringField(_("Private code"), validators=[DataRequired()]) contact_email = StringField(_("Email"), validators=[DataRequired(), Email()]) - logging_preferences = SelectField( - _("Logging Preferences"), - choices=LoggingMode.choices(), - coerce=LoggingMode.coerce, - default=LoggingMode.default(), - validators=[DataRequired()], - ) + project_history = BooleanField(_("Enable project history")) + ip_recording = BooleanField(_("Use IP tracking for project history")) def save(self): """Create a new project with the information given by this form. Returns the created instance """ + if not self.project_history.data: + new_logging_preference = LoggingMode.DISABLED + else: + if self.ip_recording.data: + new_logging_preference = LoggingMode.RECORD_IP + else: + new_logging_preference = LoggingMode.ENABLED + project = Project( name=self.name.data, id=self.id.data, password=generate_password_hash(self.password.data), contact_email=self.contact_email.data, - logging_preference=self.logging_preferences.data, + logging_preference=new_logging_preference, ) return project @@ -120,7 +123,16 @@ def update(self, project): project.password = generate_password_hash(self.password.data) project.contact_email = self.contact_email.data - project.logging_preference = self.logging_preferences.data + + if not self.project_history.data: + new_logging_preference = LoggingMode.DISABLED + else: + if self.ip_recording.data: + new_logging_preference = LoggingMode.RECORD_IP + else: + new_logging_preference = LoggingMode.ENABLED + + project.logging_preference = new_logging_preference return project @@ -139,6 +151,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/static/js/ihatemoney.js b/ihatemoney/static/js/ihatemoney.js index 8a7145dfc..036545e81 100644 --- a/ihatemoney/static/js/ihatemoney.js +++ b/ihatemoney/static/js/ihatemoney.js @@ -4,47 +4,4 @@ function selectCheckboxes(value){ for(var i = 0; i < els.length; i++){ els[i].checked = value; } -} - -function updateCheckBoxesFromPrivacySelect() { - var history_checkbox = document.getElementById('logging_enabled'); - var record_ip_checkbox = document.getElementById('record_ip'); - var record_ip_checkbox_text = document.getElementById("record_ip_label"); - var select_input = document.getElementById("logging_preferences"); - - if (select_input.selectedIndex === 0) { - history_checkbox.checked = false; - record_ip_checkbox.checked = false; - record_ip_checkbox.disabled = true; - record_ip_checkbox_text.classList.add("text-muted"); - } else if (select_input.selectedIndex === 1 || select_input.selectedIndex === 2) { - history_checkbox.checked = true; - record_ip_checkbox.disabled = false; - record_ip_checkbox_text.classList.remove("text-muted"); - if (select_input.selectedIndex === 2) { - record_ip_checkbox.checked = true - } - } -} - -function updatePrivacySelectFromCheckBoxes() { - var history_checkbox = document.getElementById('logging_enabled'); - var record_ip_checkbox = document.getElementById('record_ip'); - var record_ip_checkbox_text = document.getElementById("record_ip_label"); - var select_input = document.getElementById("logging_preferences"); - - if (!history_checkbox.checked) { - record_ip_checkbox.checked = false; - record_ip_checkbox.disabled = true; - record_ip_checkbox_text.classList.add("text-muted"); - select_input.selectedIndex = 0 - } else { - record_ip_checkbox.disabled = false; - record_ip_checkbox_text.classList.remove("text-muted"); - if (record_ip_checkbox.checked){ - select_input.selectedIndex = 2 - } else { - select_input.selectedIndex = 1 - } - } } \ No newline at end of file diff --git a/ihatemoney/templates/forms.html b/ihatemoney/templates/forms.html index 7401a80b0..33a283f26 100644 --- a/ihatemoney/templates/forms.html +++ b/ihatemoney/templates/forms.html @@ -20,6 +20,16 @@

{% endmacro %} +{% macro checkbox(field) %} +
+ {{ field(id=field.name) }} + + {% if field.description %} + {{ field.description }} + {% endif %} +
+{% endmacro %} + {% macro submit(field, cancel=False, home=False) -%}
@@ -81,20 +91,8 @@
-
- - -
-
-
- - -
-
-
-
- {{ form.logging_preferences }} - + {{ checkbox(form.project_history) }} + {{ checkbox(form.ip_recording) }}
diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index 84b212a2c..897045ee0 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -2276,9 +2276,13 @@ def change_privacy_to(self, logging_preference): "name": "demo", "contact_email": "demo@notmyidea.org", "password": "demo", - "logging_preferences": logging_preference.value, } + 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) @@ -2286,11 +2290,17 @@ def change_privacy_to(self, logging_preference): resp = self.client.get("/demo/edit") self.assertEqual(resp.status_code, 200) - self.assertIn( - '' - % (logging_preference.value, logging_preference.name), - resp.data.decode("utf-8"), - ) + if logging_preference == LoggingMode.DISABLED: + self.assertIn('ENABLED', resp.data.decode("utf-8") + '', + resp.data.decode("utf-8"), ) self.change_privacy_to(LoggingMode.DISABLED) @@ -2415,10 +2427,14 @@ def do_misc_database_operations(self, logging_mode): "name": "demo2", "contact_email": "demo2@notmyidea.org", "password": "123456", - "logging_preferences": logging_mode.value, - # Keep privacy settings where they were } + # 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) diff --git a/ihatemoney/web.py b/ihatemoney/web.py index e1e0eb8b6..744d1bfa9 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -406,7 +406,12 @@ def edit_project(): return redirect(url_for("main.list_bills")) else: edit_form.name.data = g.project.name - edit_form.logging_preferences.data = g.project.logging_preference + + 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( From 470f2e58d24cbd736fb2a41a250ddd509d6ad542 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Fri, 17 Apr 2020 16:21:50 -0400 Subject: [PATCH 27/31] Fix API test --- ihatemoney/tests/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index 897045ee0..5dff64d9e 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -1531,6 +1531,7 @@ def test_project(self): "contact_email": "yeah@notmyidea.org", "password": "raclette", "name": "The raclette party", + "project_history": "y", }, headers=self.get_auth("raclette"), ) From ddd2600f61cb2105bcf55d7a3c00d096bbbb79f6 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sat, 18 Apr 2020 11:46:49 -0400 Subject: [PATCH 28/31] Remove moment.js --- ihatemoney/history.py | 2 +- ihatemoney/momentjs.py | 21 --------------------- ihatemoney/run.py | 2 -- ihatemoney/static/js/ihatemoney.js | 6 +++++- ihatemoney/static/js/moment.min.js | 1 - ihatemoney/templates/history.html | 6 +++--- ihatemoney/templates/layout.html | 1 - 7 files changed, 9 insertions(+), 30 deletions(-) delete mode 100644 ihatemoney/momentjs.py delete mode 100644 ihatemoney/static/js/moment.min.js diff --git a/ihatemoney/history.py b/ihatemoney/history.py index edd7f84a7..cda141e4a 100644 --- a/ihatemoney/history.py +++ b/ihatemoney/history.py @@ -90,7 +90,7 @@ def get_history(project, human_readable_names=True): object_str = describe_version(version) common_properties = { - "time": version.transaction.issued_at, + "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, diff --git a/ihatemoney/momentjs.py b/ihatemoney/momentjs.py deleted file mode 100644 index b5292805d..000000000 --- a/ihatemoney/momentjs.py +++ /dev/null @@ -1,21 +0,0 @@ -from jinja2 import Markup - - -class momentjs(object): - def __init__(self, timestamp): - self.timestamp = timestamp - - def render(self, format): - return Markup( - '' - % (self.timestamp.strftime("%Y-%m-%dT%H:%M:%S Z"), format) - ) - - def format(self, fmt): - return self.render('format("%s")' % fmt) - - def calendar(self): - return self.render("calendar()") - - def fromNow(self): - return self.render("fromNow()") diff --git a/ihatemoney/run.py b/ihatemoney/run.py index a09872352..161761c09 100644 --- a/ihatemoney/run.py +++ b/ihatemoney/run.py @@ -20,7 +20,6 @@ from ihatemoney.web import main as web_interface from ihatemoney import default_settings -from ihatemoney.momentjs import momentjs def setup_database(app): @@ -147,7 +146,6 @@ def create_app( app.jinja_env.globals["static_include"] = static_include app.jinja_env.globals["locale_from_iso"] = locale_from_iso app.jinja_env.filters["minimal_round"] = minimal_round - app.jinja_env.globals["momentjs"] = momentjs # Translations babel = Babel(app) diff --git a/ihatemoney/static/js/ihatemoney.js b/ihatemoney/static/js/ihatemoney.js index 036545e81..9ffc8771c 100644 --- a/ihatemoney/static/js/ihatemoney.js +++ b/ihatemoney/static/js/ihatemoney.js @@ -4,4 +4,8 @@ function selectCheckboxes(value){ for(var i = 0; i < els.length; i++){ els[i].checked = value; } -} \ No newline at end of file +} + +function localizeTime(utcTimestamp) { + return new Date(utcTimestamp).toLocaleString() +} diff --git a/ihatemoney/static/js/moment.min.js b/ihatemoney/static/js/moment.min.js deleted file mode 100644 index 5787a4085..000000000 --- a/ihatemoney/static/js/moment.min.js +++ /dev/null @@ -1 +0,0 @@ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.moment=t()}(this,function(){"use strict";var e,i;function c(){return e.apply(null,arguments)}function o(e){return e instanceof Array||"[object Array]"===Object.prototype.toString.call(e)}function u(e){return null!=e&&"[object Object]"===Object.prototype.toString.call(e)}function l(e){return void 0===e}function h(e){return"number"==typeof e||"[object Number]"===Object.prototype.toString.call(e)}function d(e){return e instanceof Date||"[object Date]"===Object.prototype.toString.call(e)}function f(e,t){var n,s=[];for(n=0;n>>0,s=0;sSe(e)?(r=e+1,o-Se(e)):(r=e,o),{year:r,dayOfYear:a}}function Ie(e,t,n){var s,i,r=Ve(e.year(),t,n),a=Math.floor((e.dayOfYear()-r-1)/7)+1;return a<1?s=a+Ae(i=e.year()-1,t,n):a>Ae(e.year(),t,n)?(s=a-Ae(e.year(),t,n),i=e.year()+1):(i=e.year(),s=a),{week:s,year:i}}function Ae(e,t,n){var s=Ve(e,t,n),i=Ve(e+1,t,n);return(Se(e)-s+i)/7}I("w",["ww",2],"wo","week"),I("W",["WW",2],"Wo","isoWeek"),C("week","w"),C("isoWeek","W"),F("week",5),F("isoWeek",5),ue("w",B),ue("ww",B,z),ue("W",B),ue("WW",B,z),fe(["w","ww","W","WW"],function(e,t,n,s){t[s.substr(0,1)]=D(e)});function je(e,t){return e.slice(t,7).concat(e.slice(0,t))}I("d",0,"do","day"),I("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),I("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),I("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),I("e",0,0,"weekday"),I("E",0,0,"isoWeekday"),C("day","d"),C("weekday","e"),C("isoWeekday","E"),F("day",11),F("weekday",11),F("isoWeekday",11),ue("d",B),ue("e",B),ue("E",B),ue("dd",function(e,t){return t.weekdaysMinRegex(e)}),ue("ddd",function(e,t){return t.weekdaysShortRegex(e)}),ue("dddd",function(e,t){return t.weekdaysRegex(e)}),fe(["dd","ddd","dddd"],function(e,t,n,s){var i=n._locale.weekdaysParse(e,s,n._strict);null!=i?t.d=i:g(n).invalidWeekday=e}),fe(["d","e","E"],function(e,t,n,s){t[s]=D(e)});var Ze="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_");var ze="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_");var $e="Su_Mo_Tu_We_Th_Fr_Sa".split("_");var qe=ae;var Je=ae;var Be=ae;function Qe(){function e(e,t){return t.length-e.length}var t,n,s,i,r,a=[],o=[],u=[],l=[];for(t=0;t<7;t++)n=y([2e3,1]).day(t),s=this.weekdaysMin(n,""),i=this.weekdaysShort(n,""),r=this.weekdays(n,""),a.push(s),o.push(i),u.push(r),l.push(s),l.push(i),l.push(r);for(a.sort(e),o.sort(e),u.sort(e),l.sort(e),t=0;t<7;t++)o[t]=he(o[t]),u[t]=he(u[t]),l[t]=he(l[t]);this._weekdaysRegex=new RegExp("^("+l.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+u.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+o.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+a.join("|")+")","i")}function Xe(){return this.hours()%12||12}function Ke(e,t){I(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function et(e,t){return t._meridiemParse}I("H",["HH",2],0,"hour"),I("h",["hh",2],0,Xe),I("k",["kk",2],0,function(){return this.hours()||24}),I("hmm",0,0,function(){return""+Xe.apply(this)+L(this.minutes(),2)}),I("hmmss",0,0,function(){return""+Xe.apply(this)+L(this.minutes(),2)+L(this.seconds(),2)}),I("Hmm",0,0,function(){return""+this.hours()+L(this.minutes(),2)}),I("Hmmss",0,0,function(){return""+this.hours()+L(this.minutes(),2)+L(this.seconds(),2)}),Ke("a",!0),Ke("A",!1),C("hour","h"),F("hour",13),ue("a",et),ue("A",et),ue("H",B),ue("h",B),ue("k",B),ue("HH",B,z),ue("hh",B,z),ue("kk",B,z),ue("hmm",Q),ue("hmmss",X),ue("Hmm",Q),ue("Hmmss",X),ce(["H","HH"],ge),ce(["k","kk"],function(e,t,n){var s=D(e);t[ge]=24===s?0:s}),ce(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),ce(["h","hh"],function(e,t,n){t[ge]=D(e),g(n).bigHour=!0}),ce("hmm",function(e,t,n){var s=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s)),g(n).bigHour=!0}),ce("hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s,2)),t[pe]=D(e.substr(i)),g(n).bigHour=!0}),ce("Hmm",function(e,t,n){var s=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s))}),ce("Hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s,2)),t[pe]=D(e.substr(i))});var tt,nt=Te("Hours",!0),st={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:Ce,monthsShort:He,week:{dow:0,doy:6},weekdays:Ze,weekdaysMin:$e,weekdaysShort:ze,meridiemParse:/[ap]\.?m?\.?/i},it={},rt={};function at(e){return e?e.toLowerCase().replace("_","-"):e}function ot(e){var t=null;if(!it[e]&&"undefined"!=typeof module&&module&&module.exports)try{t=tt._abbr,require("./locale/"+e),ut(t)}catch(e){}return it[e]}function ut(e,t){var n;return e&&((n=l(t)?ht(e):lt(e,t))?tt=n:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),tt._abbr}function lt(e,t){if(null===t)return delete it[e],null;var n,s=st;if(t.abbr=e,null!=it[e])T("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),s=it[e]._config;else if(null!=t.parentLocale)if(null!=it[t.parentLocale])s=it[t.parentLocale]._config;else{if(null==(n=ot(t.parentLocale)))return rt[t.parentLocale]||(rt[t.parentLocale]=[]),rt[t.parentLocale].push({name:e,config:t}),null;s=n._config}return it[e]=new P(x(s,t)),rt[e]&&rt[e].forEach(function(e){lt(e.name,e.config)}),ut(e),it[e]}function ht(e){var t;if(e&&e._locale&&e._locale._abbr&&(e=e._locale._abbr),!e)return tt;if(!o(e)){if(t=ot(e))return t;e=[e]}return function(e){for(var t,n,s,i,r=0;r=t&&a(i,n,!0)>=t-1)break;t--}r++}return tt}(e)}function dt(e){var t,n=e._a;return n&&-2===g(e).overflow&&(t=n[_e]<0||11Pe(n[me],n[_e])?ye:n[ge]<0||24Ae(n,r,a)?g(e)._overflowWeeks=!0:null!=u?g(e)._overflowWeekday=!0:(o=Ee(n,s,i,r,a),e._a[me]=o.year,e._dayOfYear=o.dayOfYear)}(e),null!=e._dayOfYear&&(r=ct(e._a[me],s[me]),(e._dayOfYear>Se(r)||0===e._dayOfYear)&&(g(e)._overflowDayOfYear=!0),n=Ge(r,0,e._dayOfYear),e._a[_e]=n.getUTCMonth(),e._a[ye]=n.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=a[t]=s[t];for(;t<7;t++)e._a[t]=a[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[ge]&&0===e._a[ve]&&0===e._a[pe]&&0===e._a[we]&&(e._nextDay=!0,e._a[ge]=0),e._d=(e._useUTC?Ge:function(e,t,n,s,i,r,a){var o;return e<100&&0<=e?(o=new Date(e+400,t,n,s,i,r,a),isFinite(o.getFullYear())&&o.setFullYear(e)):o=new Date(e,t,n,s,i,r,a),o}).apply(null,a),i=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[ge]=24),e._w&&void 0!==e._w.d&&e._w.d!==i&&(g(e).weekdayMismatch=!0)}}var mt=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,_t=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,yt=/Z|[+-]\d\d(?::?\d\d)?/,gt=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],vt=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],pt=/^\/?Date\((\-?\d+)/i;function wt(e){var t,n,s,i,r,a,o=e._i,u=mt.exec(o)||_t.exec(o);if(u){for(g(e).iso=!0,t=0,n=gt.length;tn.valueOf():n.valueOf()this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},mn.isLocal=function(){return!!this.isValid()&&!this._isUTC},mn.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},mn.isUtc=Et,mn.isUTC=Et,mn.zoneAbbr=function(){return this._isUTC?"UTC":""},mn.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},mn.dates=n("dates accessor is deprecated. Use date instead.",un),mn.months=n("months accessor is deprecated. Use month instead",Ue),mn.years=n("years accessor is deprecated. Use year instead",Oe),mn.zone=n("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?("string"!=typeof e&&(e=-e),this.utcOffset(e,t),this):-this.utcOffset()}),mn.isDSTShifted=n("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!l(this._isDSTShifted))return this._isDSTShifted;var e={};if(w(e,this),(e=Ot(e))._a){var t=e._isUTC?y(e._a):bt(e._a);this._isDSTShifted=this.isValid()&&0{{ _('Confirm Remove IP Adresses') }} {% if history %} - - + + {% for event in history %} - +
{{ _("Time") }}{{ _("Event") }}{{ _("Time") }}{{ _("Event") }} {{ _('Confirm Remove IP Adresses') }}
{{ momentjs(event.time).calendar() }}
- {%- if request.path == "/dashboard" %} From dc9a420bfa72920b559596439d4bae1124ffb15f Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sat, 18 Apr 2020 11:56:27 -0400 Subject: [PATCH 29/31] Remove identical elif branch --- ihatemoney/templates/history.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/ihatemoney/templates/history.html b/ihatemoney/templates/history.html index 41572d932..58f6c6b95 100644 --- a/ihatemoney/templates/history.html +++ b/ihatemoney/templates/history.html @@ -4,8 +4,6 @@ {% if event.val_after == LoggingMode.DISABLED %} {% if event.val_before == LoggingMode.ENABLED %} {{ _("Disabled Project History") }} - {% elif event.val_before == LoggingMode.RECORD_IP %} - {{ _("Disabled Project History & IP Address Recording") }} {% else %} {{ _("Disabled Project History & IP Address Recording") }} {% endif %} From 895ba61d742dbce44bb6c41dad93dd9921f39437 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sat, 18 Apr 2020 12:53:33 -0400 Subject: [PATCH 30/31] Replace duplicate if with property method --- ihatemoney/forms.py | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index aed0c97a6..495eefa12 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -92,25 +92,28 @@ class EditProjectForm(FlaskForm): project_history = BooleanField(_("Enable project history")) ip_recording = BooleanField(_("Use IP tracking for project history")) - def save(self): - """Create a new project with the information given by this form. - - Returns the created instance - """ + @property + def logging_preference(self): + """Get the LoggingMode object corresponding to current form data.""" if not self.project_history.data: - new_logging_preference = LoggingMode.DISABLED + return LoggingMode.DISABLED else: if self.ip_recording.data: - new_logging_preference = LoggingMode.RECORD_IP + return LoggingMode.RECORD_IP else: - new_logging_preference = LoggingMode.ENABLED + return LoggingMode.ENABLED + + def save(self): + """Create a new project with the information given by this form. + Returns the created instance + """ project = Project( name=self.name.data, id=self.id.data, password=generate_password_hash(self.password.data), contact_email=self.contact_email.data, - logging_preference=new_logging_preference, + logging_preference=self.logging_preference, ) return project @@ -123,16 +126,7 @@ def update(self, project): project.password = generate_password_hash(self.password.data) project.contact_email = self.contact_email.data - - if not self.project_history.data: - new_logging_preference = LoggingMode.DISABLED - else: - if self.ip_recording.data: - new_logging_preference = LoggingMode.RECORD_IP - else: - new_logging_preference = LoggingMode.ENABLED - - project.logging_preference = new_logging_preference + project.logging_preference = self.logging_preference return project From 80bc2ac24e90261a31d4984c0331197cf9e84f93 Mon Sep 17 00:00:00 2001 From: Andrew Dickinson Date: Sat, 18 Apr 2020 13:34:41 -0400 Subject: [PATCH 31/31] Add persistent delete history buttons --- ihatemoney/static/css/main.css | 7 +++ ihatemoney/static/images/x.svg | 8 +++ ihatemoney/templates/history.html | 101 +++++++++++++++++------------- 3 files changed, 74 insertions(+), 42 deletions(-) create mode 100644 ihatemoney/static/images/x.svg diff --git a/ihatemoney/static/css/main.css b/ihatemoney/static/css/main.css index cf47fddec..7d91c38df 100644 --- a/ihatemoney/static/css/main.css +++ b/ihatemoney/static/css/main.css @@ -549,6 +549,13 @@ 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; diff --git a/ihatemoney/static/images/x.svg b/ihatemoney/static/images/x.svg new file mode 100644 index 000000000..3416d7afc --- /dev/null +++ b/ihatemoney/static/images/x.svg @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/ihatemoney/templates/history.html b/ihatemoney/templates/history.html index 58f6c6b95..875040e4e 100644 --- a/ihatemoney/templates/history.html +++ b/ihatemoney/templates/history.html @@ -38,6 +38,49 @@ {{ _("to") }} {{ event.val_after }} {% endmacro %} +{% macro clear_history_modals() %} + + + + +{% endmacro %} {% macro owers_changed(event, add) %} {{ describe_object(event) }}: {% if add %}{{ _("Added") }}{% else %}{{ _("Removed") }}{% endif %} @@ -88,55 +131,29 @@ {% if history %}

{{ _("The table below reflects actions recorded prior to disabling project history. You can ") }} {{ _("clear project history") }} {{ _("to remove them.") }}

- - {% endif %} {% endif %} {% if current_log_pref != LoggingMode.RECORD_IP and any_ip_addresses %}

{{ _("Some entries below contain IP addresses, even though this project has IP recording disabled. ") }} - {{ _(" Delete stored IP addresses ") }}

- - + {{ _("Delete stored IP addresses") }}

{% endif %}
{% endif %} + {{ clear_history_modals() }} + + + {{ static_include("images/x.svg") | safe }} + {{ _("Clear Project History") }} + + + + + {{ static_include("images/x.svg") | safe }} + {{ _("Delete Stored IP Addresses") }} + + + +
{% if history %}