Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Project History Page #553

Merged
merged 32 commits into from
Apr 20, 2020
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
edaab55
Add logging preferences
Andrew-Dickinson Apr 11, 2020
5ebcc8b
Integrate SQLAlchemy-Continuum to support version tracking
Andrew-Dickinson Apr 11, 2020
745c3fb
Add moment.js w/ Jinja integration
Andrew-Dickinson Apr 13, 2020
71c12b5
Only update password if changed to prevent spurious log entries
Andrew-Dickinson Apr 13, 2020
ade3e26
Add __str__ functions to all logged models
Andrew-Dickinson Apr 13, 2020
9b3dfa5
Refactor versioning classes to avoid circular dependencies
Andrew-Dickinson Apr 13, 2020
b9f5658
Add functions to query project history
Andrew-Dickinson Apr 13, 2020
c95e50a
Add history page and endpoints
Andrew-Dickinson Apr 13, 2020
0d4fe30
Add SQLAlchemy to dependencies
Andrew-Dickinson Apr 13, 2020
e6013a2
Add logging_preference item to API expected output
Andrew-Dickinson Apr 13, 2020
bd30892
Whitespace tweaks in history.html
Andrew-Dickinson Apr 13, 2020
5d10374
Made logging_preference a required field in the project edit form
Andrew-Dickinson Apr 13, 2020
86872c9
Add versioning and history tests
Andrew-Dickinson Apr 13, 2020
e9831d9
Merge branch 'master' of https://github.com/spiral-project/ihatemoney…
Andrew-Dickinson Apr 13, 2020
f16495a
Change logging_preference to be NOT NULL
Andrew-Dickinson Apr 13, 2020
e9fa30f
Change some assertTrue's to assertEquals
Andrew-Dickinson Apr 13, 2020
32ad3d7
Convert whitespace mess to assertRegex() calls
Andrew-Dickinson Apr 13, 2020
931b3f8
Added test cases exposing id duplication and owers change bugs
Andrew-Dickinson Apr 13, 2020
d008e88
Remove hardcoded ids from history tests
Andrew-Dickinson Apr 13, 2020
e7848e3
Configure SQLite to disable ID reuse
Andrew-Dickinson Apr 13, 2020
5cc8b5a
Replace loop in web.py with generator expression
Andrew-Dickinson Apr 13, 2020
825386c
Add test to verify unrelated-change owers bug without web requests
Andrew-Dickinson Apr 17, 2020
e3e889e
Work-around to patch SQLAlchemy-Continuum 1.3.9 to fix the unrelated …
Andrew-Dickinson Apr 17, 2020
24c38fa
Mark RegEx strings as raw
Andrew-Dickinson Apr 17, 2020
9a875fd
Revert Mistakenly Committed Debugging Change
Andrew-Dickinson Apr 17, 2020
114c151
Mark RegEx string as raw
Andrew-Dickinson Apr 17, 2020
0b206da
Replace JavaScript-based dependent checkboxes with WTForms BooleanFields
Andrew-Dickinson Apr 17, 2020
470f2e5
Fix API test
Andrew-Dickinson Apr 17, 2020
ddd2600
Remove moment.js
Andrew-Dickinson Apr 18, 2020
dc9a420
Remove identical elif branch
Andrew-Dickinson Apr 18, 2020
895ba61
Replace duplicate if with property method
Andrew-Dickinson Apr 18, 2020
80bc2ac
Add persistent delete history buttons
Andrew-Dickinson Apr 18, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions ihatemoney/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@

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
from jinja2 import Markup

import email_validator

from ihatemoney.models import Project, Person
from ihatemoney.models import Project, Person, LoggingMode
from ihatemoney.utils import slugify, eval_arithmetic_expression


Expand Down Expand Up @@ -89,6 +89,13 @@ 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()],
)

def save(self):
"""Create a new project with the information given by this form.
Expand All @@ -100,14 +107,20 @@ 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

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

return project

Expand Down
139 changes: 139 additions & 0 deletions ihatemoney/history.py
Original file line number Diff line number Diff line change
@@ -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)
Loading