Send reference mail only when event changed significantly #161

This commit is contained in:
Daniel Grams 2021-04-29 15:06:47 +02:00
parent 85e818ef01
commit 023aca094f
10 changed files with 243 additions and 25 deletions

6
.vscode/launch.json vendored
View File

@ -47,6 +47,12 @@
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
},
{
"name": "Debug Unit Test",
"type": "python",
"request": "test",
"justMyCode": false,
}
]
}

View File

@ -25,6 +25,7 @@ from project.oauth2 import require_oauth
from project.services.event import (
get_event_with_details_or_404,
get_events_query,
get_significant_event_changes,
update_event,
)
from project.services.event_search import EventSearchParams
@ -59,8 +60,11 @@ class EventResource(BaseResource):
event = self.update_instance(EventPostRequestSchema, instance=event)
update_event(event)
changes = get_significant_event_changes(event)
db.session.commit()
send_referenced_event_changed_mails(event)
if changes:
send_referenced_event_changed_mails(event)
return make_response("", 204)
@ -75,8 +79,11 @@ class EventResource(BaseResource):
event = self.update_instance(EventPatchRequestSchema, instance=event)
update_event(event)
changes = get_significant_event_changes(event)
db.session.commit()
send_referenced_event_changed_mails(event)
if changes:
send_referenced_event_changed_mails(event)
return make_response("", 204)

View File

@ -37,12 +37,13 @@ class BaseResource(MethodResource):
return instance
def update_instance(self, schema_cls, instance):
instance = schema_cls().load(
request.json, session=db.session, instance=instance
)
with db.session.no_autoflush:
instance = schema_cls().load(
request.json, session=db.session, instance=instance
)
validate = getattr(instance, "validate", None)
if callable(validate):
validate()
validate = getattr(instance, "validate", None)
if callable(validate):
validate()
return instance

View File

@ -353,6 +353,10 @@ class UpdateEventForm(BaseEventForm):
obj.photo = Image()
field.populate_obj(obj, name)
if obj.photo and obj.photo.is_empty():
obj.photo = None
obj.photo_id = None
class DeleteEventForm(FlaskForm):
submit = SubmitField(lazy_gettext("Delete event"))

View File

@ -15,9 +15,11 @@ from project.models import (
EventOrganizer,
EventPlace,
EventReference,
EventStatus,
Image,
Location,
)
from project.utils import get_pending_changes
def get_event_category(category_name):
@ -278,12 +280,15 @@ def update_event_dates_with_recurrence_rule(event):
def insert_event(event):
if not event.status:
event.status = EventStatus.scheduled
update_event_dates_with_recurrence_rule(event)
db.session.add(event)
def update_event(event):
update_event_dates_with_recurrence_rule(event)
with db.session.no_autoflush:
update_event_dates_with_recurrence_rule(event)
def get_upcoming_event_dates(event_id):
@ -294,3 +299,17 @@ def get_upcoming_event_dates(event_id):
.order_by(EventDate.start)
.all()
)
def get_significant_event_changes(event) -> dict:
keys = [
"name",
"start",
"recurrence_rule",
"status",
"attendance_mode",
"booked_up",
"event_place_id",
"organizer_id",
]
return get_pending_changes(event, include_collections=False, include_keys=keys)

View File

@ -4,6 +4,7 @@ import pathlib
from flask_babelex import lazy_gettext
from psycopg2.errorcodes import CHECK_VIOLATION, UNIQUE_VIOLATION
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.base import NO_CHANGE, object_state
def get_event_category_name(category):
@ -56,3 +57,35 @@ def make_check_violation(message: str = None, statement: str = "") -> IntegrityE
def make_unique_violation(message: str = None, statement: str = "") -> IntegrityError:
return make_integrity_error(UNIQUE_VIOLATION, message, statement)
def get_pending_changes(
instance, include_collections=True, passive=None, include_keys=None
) -> dict:
result = {}
state = object_state(instance)
if not state.modified: # pragma: no cover
return result
dict_ = state.dict
for attr in state.manager.attributes:
if (
(include_keys and attr.key not in include_keys)
or (not include_collections and hasattr(attr.impl, "get_collection"))
or not hasattr(attr.impl, "get_history")
): # pragma: no cover
continue
(added, unchanged, deleted) = attr.impl.get_history(
state, dict_, passive=NO_CHANGE
)
if added or deleted:
old_value = deleted[0] if deleted else None
new_value = added[0] if added else None
result[attr.key] = [new_value, old_value]
return result

View File

@ -30,6 +30,7 @@ from project.models import (
)
from project.services.event import (
get_event_with_details_or_404,
get_significant_event_changes,
get_upcoming_event_dates,
insert_event,
update_event,
@ -179,8 +180,11 @@ def event_update(event_id):
try:
update_event(event)
changes = get_significant_event_changes(event)
db.session.commit()
send_referenced_event_changed_mails(event)
if changes:
send_referenced_event_changed_mails(event)
flash_message(
gettext("Event successfully updated"),
url_for("event", event_id=event.id),
@ -304,10 +308,11 @@ def prepare_event_form_for_suggestion(form, event_suggestion):
def update_event_with_form(event, form, event_suggestion=None):
form.populate_obj(event)
event.categories = EventCategory.query.filter(
EventCategory.id.in_(form.category_ids.data)
).all()
with db.session.no_autoflush:
form.populate_obj(event)
event.categories = EventCategory.query.filter(
EventCategory.id.in_(form.category_ids.data)
).all()
def get_user_rights(event):

View File

@ -314,6 +314,48 @@ def test_put_dateWithoutTimezone(client, seeder, utils, app):
assert event.start == expected
def test_put_referencedEventUpdate_sendsMail(client, seeder, utils, app, mocker):
user_id, admin_unit_id = seeder.setup_api_access()
event_id = seeder.create_event_via_api(admin_unit_id)
place_id = seeder.upsert_default_event_place(admin_unit_id)
organizer_id = seeder.upsert_default_event_organizer(admin_unit_id)
other_user_id = seeder.create_user("other@test.de")
other_admin_unit_id = seeder.create_admin_unit(other_user_id, "Other Crew")
seeder.create_reference(event_id, other_admin_unit_id)
mail_mock = utils.mock_send_mails(mocker)
url = utils.get_url("api_v1_event", id=event_id)
put = create_put(place_id, organizer_id)
put["name"] = "Changed name"
response = utils.put_json(url, put)
utils.assert_response_no_content(response)
utils.assert_send_mail_called(mail_mock, "other@test.de")
def test_put_referencedEventNonDirtyUpdate_doesNotSendMail(
client, seeder, utils, app, mocker
):
user_id, admin_unit_id = seeder.setup_api_access()
event_id = seeder.create_event_via_api(admin_unit_id)
place_id = seeder.upsert_default_event_place(admin_unit_id)
organizer_id = seeder.upsert_default_event_organizer(admin_unit_id)
other_user_id = seeder.create_user("other@test.de")
other_admin_unit_id = seeder.create_admin_unit(other_user_id, "Other Crew")
seeder.create_reference(event_id, other_admin_unit_id)
mail_mock = utils.mock_send_mails(mocker)
url = utils.get_url("api_v1_event", id=event_id)
put = create_put(place_id, organizer_id)
put["name"] = "Name"
response = utils.put_json(url, put)
utils.assert_response_no_content(response)
mail_mock.assert_not_called()
def test_patch(client, seeder, utils, app):
user_id, admin_unit_id = seeder.setup_api_access()
event_id = seeder.create_event(admin_unit_id)
@ -342,6 +384,22 @@ def test_patch_startAfterEnd(client, seeder, utils, app):
utils.assert_response_bad_request(response)
def test_patch_referencedEventUpdate_sendsMail(client, seeder, utils, app, mocker):
user_id, admin_unit_id = seeder.setup_api_access()
event_id = seeder.create_event_via_api(admin_unit_id)
other_user_id = seeder.create_user("other@test.de")
other_admin_unit_id = seeder.create_admin_unit(other_user_id, "Other Crew")
seeder.create_reference(event_id, other_admin_unit_id)
mail_mock = utils.mock_send_mails(mocker)
url = utils.get_url("api_v1_event", id=event_id)
response = utils.patch_json(url, {"name": "Changed name"})
utils.assert_response_no_content(response)
utils.assert_send_mail_called(mail_mock, "other@test.de")
def test_delete(client, seeder, utils, app):
user_id, admin_unit_id = seeder.setup_api_access()
event_id = seeder.create_event(admin_unit_id)

View File

@ -176,8 +176,7 @@ class Seeder(object):
category = get_event_category(category_name)
return category.id
def create_event(self, admin_unit_id, recurrence_rule=None, external_link=None):
from project.dateutils import get_now
def create_event(self, admin_unit_id, recurrence_rule="", external_link=""):
from project.models import Event
from project.services.event import insert_event, upsert_event_category
@ -187,16 +186,65 @@ class Seeder(object):
event.categories = [upsert_event_category("Other")]
event.name = "Name"
event.description = "Beschreibung"
event.start = get_now()
event.start = self.get_now_by_minute()
event.event_place_id = self.upsert_default_event_place(admin_unit_id)
event.organizer_id = self.upsert_default_event_organizer(admin_unit_id)
event.recurrence_rule = recurrence_rule
event.external_link = external_link
event.ticket_link = ""
event.tags = ""
event.price_info = ""
insert_event(event)
self._db.session.commit()
event_id = event.id
return event_id
def create_event_via_form(self, admin_unit_id: int) -> str:
place_id = self.upsert_default_event_place(admin_unit_id)
organizer_id = self.upsert_default_event_organizer(admin_unit_id)
url = self._utils.get_url("event_create_for_admin_unit_id", id=admin_unit_id)
response = self._utils.get_ok(url)
response = self._utils.post_form(
url,
response,
{
"name": "Name",
"description": "Beschreibung",
"start": ["2030-12-31", "23", "59"],
"event_place_id": place_id,
"organizer_id": organizer_id,
"photo-image_base64": self.get_default_image_upload_base64(),
},
)
with self._app.app_context():
from project.models import Event
event = (
Event.query.filter(Event.admin_unit_id == admin_unit_id)
.filter(Event.name == "Name")
.first()
)
return event.id
def create_event_via_api(self, admin_unit_id: int) -> int:
place_id = self.upsert_default_event_place(admin_unit_id)
organizer_id = self.upsert_default_event_organizer(admin_unit_id)
url = self._utils.get_url("api_v1_organization_event_list", id=admin_unit_id)
response = self._utils.post_json(
url,
{
"name": "Name",
"start": "2021-02-07T11:00:00.000Z",
"place": {"id": place_id},
"organizer": {"id": organizer_id},
},
)
self._utils.assert_response_created(response)
assert "id" in response.json
return response.json["id"]
def get_default_image_base64(self):
return """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"""
@ -217,7 +265,6 @@ class Seeder(object):
return image_id
def create_event_suggestion(self, admin_unit_id, free_text=False):
from project.dateutils import get_now
from project.models import EventSuggestion
from project.services.event import upsert_event_category
from project.services.event_suggestion import insert_event_suggestion
@ -230,7 +277,7 @@ class Seeder(object):
suggestion.contact_email_notice = True
suggestion.name = "Vorschlag"
suggestion.description = "Beschreibung"
suggestion.start = get_now()
suggestion.start = self.get_now_by_minute()
suggestion.photo_id = self.upsert_default_image()
suggestion.categories = [upsert_event_category("Other")]
@ -290,3 +337,13 @@ class Seeder(object):
event_id = self.create_event(other_admin_unit_id)
reference_request_id = self.create_reference_request(event_id, admin_unit_id)
return (other_user_id, other_admin_unit_id, event_id, reference_request_id)
def get_now_by_minute(self):
from datetime import datetime
from project.dateutils import get_now
now = get_now()
return datetime(
now.year, now.month, now.day, now.hour, now.minute, tzinfo=now.tzinfo
)

View File

@ -199,15 +199,16 @@ def test_admin_unit_references_outgoing(client, seeder, utils):
def test_referencedEventUpdate_sendsMail(client, seeder, utils, app, mocker):
user_id, admin_unit_id = seeder.setup_base()
(
other_user_id,
other_admin_unit_id,
event_id,
reference_id,
) = seeder.create_any_reference(admin_unit_id)
other_user_id = seeder.create_user("other@test.de")
other_admin_unit_id = seeder.create_admin_unit(other_user_id, "Other Crew")
utils.logout()
utils.login("other@test.de")
# Event per Form anlegen, um dieselben Default-Werte wie im Update zu haben
event_id = seeder.create_event_via_form(other_admin_unit_id)
seeder.create_reference(event_id, admin_unit_id)
url = utils.get_url("event_update", event_id=event_id)
response = utils.get_ok(url)
@ -221,3 +222,30 @@ def test_referencedEventUpdate_sendsMail(client, seeder, utils, app, mocker):
)
utils.assert_send_mail_called(mail_mock, "test@test.de")
def test_referencedEventNonDirtyUpdate_doesNotSendMail(
client, seeder, utils, app, mocker
):
user_id, admin_unit_id = seeder.setup_base()
other_user_id = seeder.create_user("other@test.de")
other_admin_unit_id = seeder.create_admin_unit(other_user_id, "Other Crew")
utils.logout()
utils.login("other@test.de")
# Event per Form anlegen, um dieselben Default-Werte wie im Update zu haben
event_id = seeder.create_event_via_form(other_admin_unit_id)
seeder.create_reference(event_id, admin_unit_id)
url = utils.get_url("event_update", event_id=event_id)
response = utils.get_ok(url)
mail_mock = utils.mock_send_mails(mocker)
response = utils.post_form(
url,
response,
{},
)
mail_mock.assert_not_called()