Skip to content

Commit

Permalink
Add Project History Page (spiral-project#553)
Browse files Browse the repository at this point in the history
Co-Authored-By: Glandos <bugs-github@antipoul.fr>

All project activity can be tracked, using SQLAlchemy-continuum.
IP addresses can optionally be recorded.
  • Loading branch information
Andrew-Dickinson authored Apr 20, 2020
1 parent da89455 commit c7af162
Show file tree
Hide file tree
Showing 19 changed files with 1,783 additions and 10 deletions.
35 changes: 31 additions & 4 deletions ihatemoney/forms.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,19 @@ class EditProjectForm(FlaskForm):
name = StringField(_("Project name"), validators=[DataRequired()])
password = StringField(_("Private code"), validators=[DataRequired()])
contact_email = StringField(_("Email"), validators=[DataRequired(), Email()])
project_history = BooleanField(_("Enable project history"))
ip_recording = BooleanField(_("Use IP tracking for project history"))

@property
def logging_preference(self):
"""Get the LoggingMode object corresponding to current form data."""
if not self.project_history.data:
return LoggingMode.DISABLED
else:
if self.ip_recording.data:
return LoggingMode.RECORD_IP
else:
return LoggingMode.ENABLED

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

def update(self, project):
"""Update the project with the information from the form"""
project.name = self.name.data
project.password = generate_password_hash(self.password.data)

# Only update password if changed to prevent spurious log entries
if not check_password_hash(project.password, self.password.data):
project.password = generate_password_hash(self.password.data)

project.contact_email = self.contact_email.data
project.logging_preference = self.logging_preference

return project

Expand All @@ -126,6 +145,14 @@ class ProjectForm(EditProjectForm):
password = PasswordField(_("Private code"), validators=[DataRequired()])
submit = SubmitField(_("Create the project"))

def save(self):
# WTForms Boolean Fields don't insert the default value when the
# request doesn't include any value the way that other fields do,
# so we'll manually do it here
self.project_history.data = LoggingMode.default() != LoggingMode.DISABLED
self.ip_recording.data = LoggingMode.default() == LoggingMode.RECORD_IP
return super().save()

def validate_id(form, field):
form.id.data = slugify(field.data)
if (form.id.data == "dashboard") or Project.query.get(form.id.data):
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.strftime("%Y-%m-%dT%H:%M:%SZ"),
"operation_type": version.operation_type,
"object_type": object_type,
"object_desc": object_str,
"ip": version.transaction.remote_addr,
}

if version.operation_type == Operation.UPDATE:
# Only iterate the changeset if the previous version
# Was logged
if version.previous:
changeset = version.changeset
if isinstance(version, BillVersion):
if version.owers != version.previous.owers:
added, removed = describe_owers_change(
version, human_readable_names
)

if added:
changeset["owers_added"] = (None, added)
if removed:
changeset["owers_removed"] = (None, removed)

for (prop, (val_before, val_after),) in changeset.items():
if human_readable_names:
if prop == "payer_id":
prop = "payer"
if val_after is not None:
val_after = describe_version(version.payer)
if version.previous and val_before is not None:
val_before = describe_version(
version.previous.payer
)
else:
val_after = None

next_event = common_properties.copy()
next_event["prop_changed"] = prop
next_event["val_before"] = val_before
next_event["val_after"] = val_after
history.append(next_event)
else:
history.append(common_properties)
else:
history.append(common_properties)

return sorted(history, key=history_sort_key, reverse=True)
Loading

0 comments on commit c7af162

Please sign in to comment.