diff --git a/LICENSES/CLA-signed-list.md b/LICENSES/CLA-signed-list.md index 309bfd91..86c63e49 100644 --- a/LICENSES/CLA-signed-list.md +++ b/LICENSES/CLA-signed-list.md @@ -16,3 +16,4 @@ C/ My company has custom contribution contract with Lutra Consulting Ltd. or I a * harminius, 18th Aril 2023 * varmar05, 12th April 2023 * lavor, 26th April 2023 +* luxusko, 25th August 2023 diff --git a/server/mergin/app.py b/server/mergin/app.py index 4277b74f..c4ae3676 100644 --- a/server/mergin/app.py +++ b/server/mergin/app.py @@ -105,6 +105,31 @@ def update_obj(self, obj): field.populate_obj(obj, name) +def create_simple_app() -> Flask: + from .config import Configuration + + app = connexion.FlaskApp(__name__, specification_dir=os.path.join(this_dir)) + flask_app = app.app + + flask_app.json_encoder = FlaskJSONEncoder + flask_app.config.from_object(Configuration) + db.init_app(flask_app) + ma.init_app(flask_app) + Migrate(flask_app, db) + flask_app.connexion_app = app + + @flask_app.cli.command() + def init_db(): + """Re-creates application database""" + print("Database initialization ...") + db.drop_all(bind=None) + db.create_all(bind=None) + db.session.commit() + print("Done. Tables created.") + + return flask_app + + def create_app(public_keys: List[str] = None) -> Flask: """Factory function to create Flask app instance""" from itsdangerous import BadTimeSignature, BadSignature @@ -119,8 +144,7 @@ def create_app(public_keys: List[str] = None) -> Flask: from .sync.commands import add_commands from .auth import register as register_auth - app = connexion.FlaskApp(__name__, specification_dir=os.path.join(this_dir)) - app.app.json_encoder = FlaskJSONEncoder + app = create_simple_app().connexion_app app.add_api( "sync/public_api.yaml", @@ -136,13 +160,9 @@ def create_app(public_keys: List[str] = None) -> Flask: validate_responses=True, ) - app.app.config.from_object(Configuration) app.app.config.from_object(SyncConfig) app.app.connexion_app = app - db.init_app(app.app) - ma.init_app(app.app) - Migrate(app.app, db) mail.init_app(app.app) app.mail = mail csrf.init_app(app.app) @@ -388,15 +408,6 @@ def config(): cfg["server_configured"] = is_server_configured() return jsonify(cfg), 200 - @application.cli.command() - def init_db(): - """Re-creates application database""" - print("Database initialization ...") - db.drop_all(bind=None) - db.create_all(bind=None) - db.session.commit() - print("Done. Tables created.") - # append project commands (from default sync module) add_commands(application) return application diff --git a/server/mergin/auth/db_events.py b/server/mergin/auth/db_events.py index 875eb4f6..618fa874 100644 --- a/server/mergin/auth/db_events.py +++ b/server/mergin/auth/db_events.py @@ -2,56 +2,9 @@ # # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial -from flask import render_template, current_app -from sqlalchemy import event - from .app import force_delete_user from .. import db -from ..auth.models import UserProfile, User - - -def before_user_profile_updated(mapper, connection, target): - """Before profile updated, inform user by sending email about that profile that changed - Just send email if user want to receive notifications - """ - from ..celery import send_email_async - - if target.receive_notifications and target.user.verified_email: - state = db.inspect(target) - changes = {} - - for attr in state.attrs: - hist = attr.load_history() - if not hist.has_changes(): - continue - - before = hist.deleted[0] - after = hist.added[0] - field = attr.key - - # if boolean, show Yes or No - if before is not None and isinstance(before, bool): - before = "Yes" if before is True else "No" - if after is not None and isinstance(after, bool): - after = "Yes" if after is True else "No" - - profile_key = field.title().replace("_", " ") - changes[profile_key] = {"before": before, "after": after} - - # inform user - if changes: - email_data = { - "subject": "Profile has been changed", - "html": render_template( - "email/profile_changed.html", - subject="Profile update", - user=target.user, - changes=changes, - ), - "recipients": [target.user.email], - "sender": current_app.config["MAIL_DEFAULT_SENDER"], - } - send_email_async.delay(**email_data) +from ..auth.models import User def permanently_delete_user(user: User): @@ -61,10 +14,8 @@ def permanently_delete_user(user: User): def register_events(): - event.listen(UserProfile, "after_update", before_user_profile_updated) force_delete_user.connect(permanently_delete_user) def remove_events(): - event.remove(UserProfile, "after_update", before_user_profile_updated) force_delete_user.disconnect(permanently_delete_user) diff --git a/server/mergin/sync/db_events.py b/server/mergin/sync/db_events.py index 3161875c..24848011 100644 --- a/server/mergin/sync/db_events.py +++ b/server/mergin/sync/db_events.py @@ -7,9 +7,8 @@ from sqlalchemy import event from .. import db -from ..auth.models import User, UserProfile +from ..auth.models import User from .models import Project, ProjectAccess -from .public_api_controller import project_deleted def remove_user_references(mapper, connection, user): # pylint: disable=W0612 @@ -35,39 +34,6 @@ def filter_user(ids): ) -def project_post_delete_actions(project: Project) -> None: # pylint: disable=W0612 - """After project is deleted inform users by sending email""" - from ..celery import send_email_async - - if not project.access: - return - users_ids = list( - set(project.access.owners + project.access.writers + project.access.readers) - ) - users_profiles = UserProfile.query.filter(UserProfile.user_id.in_(users_ids)).all() - project_workspace = project.workspace - for profile in users_profiles: - # skip the user who triggered deletion - if profile.user.username == project.removed_by: - continue - - if not (profile.receive_notifications and profile.user.verified_email): - continue - - email_data = { - "subject": f'Mergin project {"/".join([project_workspace.name, project.name])} has been deleted', - "html": render_template( - "email/removed_project.html", - subject="Project deleted", - project=project, - username=profile.user.username, - ), - "recipients": [profile.user.email], - "sender": current_app.config["MAIL_DEFAULT_SENDER"], - } - send_email_async.delay(**email_data) - - def check(session): if os.path.isfile(current_app.config["MAINTENANCE_FILE"]): abort(503, "Service unavailable due to maintenance, please try later") @@ -76,10 +42,8 @@ def check(session): def register_events(): event.listen(User, "before_delete", remove_user_references) event.listen(db.session, "before_commit", check) - project_deleted.connect(project_post_delete_actions) def remove_events(): event.remove(User, "before_delete", remove_user_references) event.remove(db.session, "before_commit", check) - project_deleted.disconnect(project_post_delete_actions) diff --git a/server/mergin/sync/private_api_controller.py b/server/mergin/sync/private_api_controller.py index 12c9fbb5..2eaf1d47 100644 --- a/server/mergin/sync/private_api_controller.py +++ b/server/mergin/sync/private_api_controller.py @@ -266,31 +266,6 @@ def unsubscribe_project(id): # pylint: disable=W0612 project.access.unset_role(current_user.id) db.session.add(project) db.session.commit() - # notify owners and the user who unsubscribed - project_path = get_project_path(project) - recipients = ( - UserProfile.query.filter( - UserProfile.user_id.in_(project.access.owners + [current_user.id]) - ) - .filter(UserProfile.receive_notifications) - .all() - ) - for profile in recipients: - if not profile.user.verified_email: - continue - html = render_template( - "email/project_unsubscribe.html", - project_path=project_path, - recipient=profile.user.username, - username=current_user.username, - ) - email_data = { - "subject": f"Access to mergin project {project_path} has been modified", - "html": html, - "recipients": [profile.user.email], - "sender": current_app.config["MAIL_DEFAULT_SENDER"], - } - send_email_async.delay(**email_data) return NoContent, 200 diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index b9bc592b..ffece318 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -18,7 +18,6 @@ from connexion import NoContent, request from flask import ( abort, - render_template, current_app, send_from_directory, jsonify, @@ -35,7 +34,7 @@ from werkzeug.exceptions import HTTPException from .. import db from ..auth import auth_required -from ..auth.models import User, UserProfile +from ..auth.models import User from .models import Project, ProjectAccess, ProjectVersion, Upload from .schemas import ( ProjectSchema, @@ -73,7 +72,6 @@ get_path_from_files, get_project_path, ) -from ..celery import send_email_async from .errors import StorageLimitHit from ..utils import format_time_delta @@ -644,49 +642,6 @@ def update_project(namespace, project_name): # noqa: E501 # pylint: disable=W0 db.session.add(project) db.session.commit() - # send email notifications about changes to users - user_profiles = UserProfile.query.filter( - UserProfile.user_id.in_(list(id_diffs)) - ).all() - project_path = "/".join([namespace, project.name]) - web_link = f"{request.url_root.strip('/')}/projects/{project_path}" - for user_profile in user_profiles: - if not ( - user_profile.receive_notifications and user_profile.user.verified_email - ): - continue - privileges = [] - if user_profile.user.id in project.access.owners: - privileges += ["edit", "remove"] - if user_profile.user.id in project.access.writers: - privileges.append("upload") - if user_profile.user.id in project.access.readers: - privileges.append("download") - subject = "Project access modified" - if len(privileges): - html = render_template( - "email/modified_project_access.html", - subject=subject, - project=project, - user=user_profile.user, - privileges=privileges, - link=web_link, - ) - else: - html = render_template( - "email/removed_project_access.html", - subject=subject, - project=project, - user=user_profile.user, - ) - - email_data = { - "subject": f"Access to mergin project {project_path} has been modified", - "html": html, - "recipients": [user_profile.user.email], - "sender": current_app.config["MAIL_DEFAULT_SENDER"], - } - send_email_async.delay(**email_data) # partial success if error: return jsonify(**error.to_dict(), project=ProjectSchema().dump(project)), 207 diff --git a/server/mergin/templates/email/modified_project_access.html b/server/mergin/templates/email/modified_project_access.html deleted file mode 100644 index 4231a32a..00000000 --- a/server/mergin/templates/email/modified_project_access.html +++ /dev/null @@ -1,17 +0,0 @@ - - -{% extends "email/components/content.html" %} -{% block html %} -
Dear {{ user.username }},
Your access privileges to Mergin Maps project {{ project.workspace.name }}/{{ project.name }} have been modified.
-You can now - {% for item in privileges %} - {{ item }}{% if not loop.last %}, {% endif %} - {% endfor %} - the project. -
-{% endblock %} diff --git a/server/mergin/templates/email/profile_changed.html b/server/mergin/templates/email/profile_changed.html deleted file mode 100644 index e461e18d..00000000 --- a/server/mergin/templates/email/profile_changed.html +++ /dev/null @@ -1,33 +0,0 @@ - - -{% extends "email/components/content.html" %} -{% block html %} -Dear {{ user.username }},
Your profile has been updated as follows:
--
- You can change values in your profile. -
-{% endblock %} diff --git a/server/mergin/templates/email/project_unsubscribe.html b/server/mergin/templates/email/project_unsubscribe.html deleted file mode 100644 index 37d37e5d..00000000 --- a/server/mergin/templates/email/project_unsubscribe.html +++ /dev/null @@ -1,15 +0,0 @@ - - -{% extends "email/components/content.html" %} -{% block html %} -Dear {{ recipient }},
You have successfully unsubscribed from project {{ project_path }}.
- {% else %} -User {{ username }} has unsubscribed from project {{ project_path }}.
- {% endif %} -{% endblock %} diff --git a/server/mergin/templates/email/removed_project.html b/server/mergin/templates/email/removed_project.html deleted file mode 100644 index e58c3b0b..00000000 --- a/server/mergin/templates/email/removed_project.html +++ /dev/null @@ -1,11 +0,0 @@ - - -{% extends "email/components/content.html" %} -{% block html %} -Dear {{ username }},
Project {{ project.workspace.name }}/{{ project.name }} which you had access to has now been removed.
-{% endblock %} diff --git a/server/mergin/templates/email/removed_project_access.html b/server/mergin/templates/email/removed_project_access.html deleted file mode 100644 index 8acd094d..00000000 --- a/server/mergin/templates/email/removed_project_access.html +++ /dev/null @@ -1,11 +0,0 @@ - - -{% extends "email/components/content.html" %} -{% block html %} -Dear {{ user.username }},
Your access to the Mergin Maps project {{ project.workspace.name }}/{{ project.name }} has been removed.
-{% endblock %} diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index 7ae028aa..3101b225 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta import pytest import json -from flask import url_for, current_app +from flask import url_for from itsdangerous import URLSafeTimedSerializer from sqlalchemy import desc from unittest.mock import patch diff --git a/server/mergin/tests/test_celery.py b/server/mergin/tests/test_celery.py index 9675c17a..0b532b44 100644 --- a/server/mergin/tests/test_celery.py +++ b/server/mergin/tests/test_celery.py @@ -10,12 +10,14 @@ from .. import db from ..config import Configuration -from ..sync.models import Project +from ..sync.models import Project, AccessRequest from ..celery import send_email_async from ..sync.tasks import remove_temp_files, remove_projects_backups from ..sync.storages.disk import move_to_tmp from . import test_project, test_workspace_name, test_workspace_id -from .utils import cleanup, add_user +from .utils import add_user, create_workspace, create_project, login +from ..auth.models import User +from . import json_headers def test_send_email(app): @@ -55,23 +57,26 @@ def test_send_email(app): @patch("mergin.celery.send_email_async.apply_async") def test_send_email_from_flask(send_email_mock, client): """Test correct data are passed to celery task which is called from endpoint.""" - usr = add_user("test1", "test") - project = Project.query.filter_by( - workspace_id=test_workspace_id, name="test" - ).first() - readers = project.access.readers.copy() - readers.append(usr.id) - project.access.readers = readers - db.session.commit() + user = User.query.filter(User.username == "mergin").first() + test_workspace = create_workspace() + p = create_project("testx", test_workspace, user) + user2 = add_user("test_user", "ilovemergin") + login(client, "test_user", "ilovemergin") email_data = { - "subject": "Mergin project mergin/test has been deleted", - "recipients": [usr.email], + "subject": "Project access requested", + "recipients": [user.email], "sender": current_app.config["MAIL_DEFAULT_SENDER"], } - resp = client.delete("/v1/project/{}/{}".format("mergin", "test")) + resp = client.post( + f"/app/project/access-request/{test_workspace.name}/{p.name}", + headers=json_headers, + ) + access_request = AccessRequest.query.filter( + AccessRequest.project_id == p.id + ).first() assert resp.status_code == 200 - # cleanup files - cleanup(client, [project.storage.project_dir]) + assert access_request.user.username == "test_user" + assert send_email_mock.called call_args, _ = send_email_mock.call_args _, kwargs = call_args diff --git a/server/mergin/tests/test_user.py b/server/mergin/tests/test_user.py deleted file mode 100644 index 6250d64c..00000000 --- a/server/mergin/tests/test_user.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright (C) Lutra Consulting Limited -# -# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial - -import json -from unittest.mock import patch - -from ..auth.models import User, UserProfile -from ..sync.models import Project -from .. import db -from ..celery import send_email_async -from . import test_project, test_workspace_name, test_workspace_id, json_headers - - -@patch("mergin.celery.send_email_async.apply_async") -def test_mail_notifications(send_email_mock, client): - project = Project.query.filter_by( - name=test_project, workspace_id=test_workspace_id - ).first() - # need for private project - project.access.public = False - db.session.add(project) - # add some tester - test_user = User( - username="tester", passwd="tester", is_admin=False, email="tester@mergin.com" - ) - test_user.verified_email = True - test_user.profile = UserProfile() - db.session.add(test_user) - test_user2 = User( - username="tester2", passwd="tester2", is_admin=False, email="tester2@mergin.com" - ) - test_user2.active = True - test_user2.verified_email = True - test_user2.profile = UserProfile() - db.session.add(test_user2) - db.session.commit() - - # add tests user as reader to project - data = {"access": {"readers": project.access.readers + [test_user.id]}} - resp = client.put( - "/v1/project/{}/{}".format(test_workspace_name, test_project), - data=json.dumps(data), - headers=json_headers, - ) - assert resp.status_code == 200 - assert test_user.id in project.access.readers - call_args, _ = send_email_mock.call_args - _, email_data = call_args - assert test_user.email in email_data["recipients"] - - # disable notifications for test_user, and promote test_user and test_user2 to writers - user_profile = UserProfile.query.filter_by(user_id=test_user.id).first() - user_profile.receive_notifications = False - data = {"access": {"writers": project.access.readers + [test_user2.id]}} - resp = client.put( - "/v1/project/{}/{}".format(test_workspace_name, test_project), - data=json.dumps(data), - headers=json_headers, - ) - assert resp.status_code == 200 - assert test_user.id in project.access.writers - assert test_user2.id in project.access.writers - call_args, _ = send_email_mock.call_args - _, email_data = call_args - # only test_user2 receives notification - assert test_user.email not in email_data["recipients"] - assert test_user2.email in email_data["recipients"]