diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 81a86ea39..3486a5f46 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,11 +6,13 @@ on: - develop - stable - 'GSOC**' + - 'prepare**' pull_request: branches: - develop - stable - 'GSOC**' + - 'prepare**' jobs: codespell: diff --git a/.github/workflows/testing-all-oses.yml b/.github/workflows/testing-all-oses.yml index 21f8b1bd6..02e3d3361 100644 --- a/.github/workflows/testing-all-oses.yml +++ b/.github/workflows/testing-all-oses.yml @@ -6,11 +6,13 @@ on: - develop - stable - 'GSOC**' + - 'prepare**' pull_request: branches: - develop - stable - 'GSOC**' + - 'prepare**' jobs: test: diff --git a/AUTHORS b/AUTHORS index db108ce63..dc92caa9f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,6 +8,7 @@ in alphabetic order by first name - Anveshan Lal - Aravind Murali - Aryan Gupta +- Annapurna Gupta - Christian Rolf - Debajyoti Dasgupta - Hrithik Kumar Verma diff --git a/docs/components.rst b/docs/components.rst index 941cdc41c..1cb64ef58 100644 --- a/docs/components.rst +++ b/docs/components.rst @@ -10,6 +10,7 @@ Components plugins mswms mscolab + view layout and restoring gentutorials mssautoplot autoplot_dock_widget diff --git a/docs/view_layout_and_restoring.rst b/docs/view_layout_and_restoring.rst new file mode 100644 index 000000000..a802fa22d --- /dev/null +++ b/docs/view_layout_and_restoring.rst @@ -0,0 +1,44 @@ +View Layout and Restoring +========================= + +Overview +-------- +The View Layout and Restoring feature allows users to **save, manage, and restore workspace layouts** across sessions and operations. +This ensures that users can continue their work seamlessly without needing to reconfigure their views each time they open the application. + +Enabling Restore Views +---------------------- +Users can control the behavior of view restoration through the `restore_views` option in the configuration: + +- **Disabled (default)**: Views behave as usual; no restoration occurs. +- **Enabled**: Loading a saved flighttrack or operation automatically restores previously opened views. + +Usage +----- +1. **Restoring Flighttrack Views** + - When opening a flighttrack, the last saved views for that flighttrack are restored automatically. + - Each flighttrack maintains its own view configuration. + +2. **Restoring an Operation Views** + - When activating anoperation, the last opened views will be restored automatically. + - When switching between operations, the application remembers the last opened views for each operation. + - Any changes made to views in an operation (adding/removing views) are saved automatically when switching operations. + - Returning to a previous operation reloads the most recent configuration. + +3. **Sharing Views** + - Users can share their views with other participants in the same operation. + - Steps to share: + 1. Activate an operation and open one or more views. + 2. Select views in the **Open Views** section. + 3. Rename views if needed. + 4. Click **Share** to publish views for other participants. + - Other users can apply shared views via the **Manage Views** widget, ensuring collaboration without recreating views manually. + +Limitations +----------- +- Unsaved flighttrack views will not be restored. +- Changes made for flighttrack while `restore_views` is enabled are only saved when switching operations or closing the application. + +Tips +---- +- Ensure each shared view has a **unique name** within an operation to avoid conflicts. diff --git a/mslib/mscolab/file_manager.py b/mslib/mscolab/file_manager.py index 0e53250c8..9580de2e7 100644 --- a/mslib/mscolab/file_manager.py +++ b/mslib/mscolab/file_manager.py @@ -34,11 +34,12 @@ import git import threading import mimetypes +import json from pathlib import Path from werkzeug.utils import secure_filename from sqlalchemy.exc import IntegrityError from mslib.utils.verify_waypoint_data import verify_waypoint_data -from mslib.mscolab.models import db, Operation, Permission, User, Change, Message +from mslib.mscolab.models import db, Operation, Permission, User, Change, Message, ViewSettings, SharedView, ManageViews from mslib.mscolab.conf import mscolab_settings @@ -790,3 +791,129 @@ def import_permissions(self, import_op_id, current_op_id, u_id): except IntegrityError: db.session.rollback() return False, None, "Some error occurred! Could not import permissions. Please try again." + + def list_views(self, op_id, user): + if not self.is_member(user.id, op_id): + return False, "Access denied", {} + views = ManageViews.query.filter_by(op_id=op_id).all() + views_data = [] + for v in views: + views_data.append({ + "id": v.id, + "op_id": v.op_id, + "u_id": v.u_id, + "view_name": v.view_name, + "created_at": v.created_at.isoformat() if v.created_at else None, + "username": v.user.username if v.user else None, + }) + return True, "Views fetched successfully", views_data + + def check_view_name(self, user, op_id, view_name): + if not (self.is_member(user.id, op_id) or self.is_viewer(user.id, op_id)): + return False, "Access denied" + return ManageViews.query.filter_by(op_id=op_id, view_name=view_name).first() is not None + + def save_manage_view_metadata(self, user, op_id, view_name): + if not (self.is_member(user.id, op_id) or self.is_viewer(user.id, op_id)): + return False, "Access denied" + try: + data = ManageViews( + op_id=op_id, + u_id=user.id, + view_name=view_name + ) + db.session.add(data) + db.session.commit() + return True, "Metadata saved to database" + except Exception as e: + logging.error("Error saving metadata for user %s: %s", user.id, str(e)) + return False, f"Error saving metadata: {str(e)}" + + def share_view(self, op_id, view_name, user, settings_data): + """Share view settings with other users in the same operation.""" + if not (self.is_member(user.id, op_id) or self.is_viewer(user.id, op_id)): + return False, "Access denied" + try: + shared_data = json.dumps(settings_data) + existing = SharedView.query.filter_by(op_id=op_id, u_id=user.id, view_name=view_name).first() + if existing: + existing.settings = shared_data + existing.updated_at = datetime.datetime.now(tz=datetime.timezone.utc) + db.session.commit() + return True, "Shared view settings updated successfully" + else: + view_setting = SharedView( + op_id=op_id, + u_id=user.id, + view_name=view_name, + shared_data=shared_data + ) + db.session.add(view_setting) + db.session.commit() + return True, "View settings shared successfully" + except Exception as e: + logging.error("Error sharing view settings for user %s: %s", user.id, str(e)) + return False, f"Failed to share view settings: {str(e)}" + + def get_shared_views(self, op_id, view_name, user): + """Retrieve shared view settings for a user from the database.""" + if not self.is_member(user.id, op_id): + return False, "Access denied", {} + if view_name: + shared_view = SharedView.query.filter_by(op_id=op_id, view_name=view_name).first() + if not shared_view: + return False, "No shared view found with that ID", {} + try: + setting = json.loads(shared_view.shared_data) + if not isinstance(setting, dict): + return False, f"Invalid settings type after parsing: {type(shared_view).__name__}", {} + return True, "View settings shared successfully", setting + except Exception as e: + return False, f"Error parsing settings: {str(e)}", {} + + def save_view_settings(self, op_id, user, view_settings): + """Save view settings for an operation and user to the database.""" + if not self.is_member(user.id, op_id) and self.is_viewer(user.id, op_id): + return False, "Access denied" + try: + settings_str = json.dumps(view_settings) + view_setting = ViewSettings.query.filter_by(u_id=user.id, op_id=op_id).first() + if view_setting: + view_setting.settings = settings_str + view_setting.updated_at = datetime.datetime.now(tz=datetime.timezone.utc) + db.session.commit() + return True, "View settings updated successfully" + else: + view_setting = ViewSettings(op_id=op_id, u_id=user.id, settings=settings_str) + db.session.add(view_setting) + db.session.commit() + return True, "View settings saved successfully" + except Exception as e: + db.session.rollback() + logging.error("Error saving view settings for user %s, operation %s: %s", user.id, op_id, str(e)) + return False, f"Failed to save view settings: {str(e)}" + + def get_view_settings(self, op_id, user): + """Retrieve view settings for an operation and user from the database.""" + if not self.is_member(user.id, op_id): + return False, "Access denied", {} + + try: + view_setting = ViewSettings.query.filter_by(u_id=user.id, op_id=op_id).first() + settings = view_setting.settings if view_setting else None + + if settings is None: + return True, "No view settings found", {"views": [], "global": {}} + + try: + settings = json.loads(settings) + if not isinstance(settings, dict): + return False, f"Invalid settings type after parsing: {type(settings).__name__}", {} + except json.JSONDecodeError: + return False, "Invalid JSON string for settings", {} + + return True, "View settings retrieved successfully", settings + + except AttributeError as e: + logging.warning("Database access error for user %s, operation %s: %s", user.id, op_id, str(e)) + return False, f"Database error: {str(e)}", {} diff --git a/mslib/mscolab/migrations/versions/2daa5c5142a1_new_models.py b/mslib/mscolab/migrations/versions/2daa5c5142a1_new_models.py new file mode 100644 index 000000000..953593c85 --- /dev/null +++ b/mslib/mscolab/migrations/versions/2daa5c5142a1_new_models.py @@ -0,0 +1,63 @@ +"""new_models + +Revision ID: 2daa5c5142a1 +Revises: 922e4d9c94e2 +Create Date: 2025-09-27 16:29:39.069372 + +""" +from alembic import op +import sqlalchemy as sa +import mslib.mscolab.custom_migration_types as cu + + +# revision identifiers, used by Alembic. +revision = '2daa5c5142a1' +down_revision = '922e4d9c94e2' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('manageviews', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('op_id', sa.Integer(), nullable=False), + sa.Column('u_id', sa.Integer(), nullable=False), + sa.Column('view_name', sa.String(length=255), nullable=False), + sa.Column('created_at', cu.AwareDateTime(), nullable=True), + sa.ForeignKeyConstraint(['op_id'], ['operations.id'], name=op.f('fk_manageviews_op_id_operations')), + sa.ForeignKeyConstraint(['u_id'], ['users.id'], name=op.f('fk_manageviews_u_id_users')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_manageviews')) + ) + op.create_table('sharedviews', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('op_id', sa.Integer(), nullable=False), + sa.Column('u_id', sa.Integer(), nullable=False), + sa.Column('view_name', sa.String(length=255), nullable=False), + sa.Column('shared_data', sa.JSON(), nullable=False), + sa.Column('created_at', cu.AwareDateTime(), nullable=True), + sa.ForeignKeyConstraint(['op_id'], ['operations.id'], name=op.f('fk_sharedviews_op_id_operations')), + sa.ForeignKeyConstraint(['u_id'], ['users.id'], name=op.f('fk_sharedviews_u_id_users')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_sharedviews')), + sa.UniqueConstraint('op_id', 'view_name', name='_op_view_uc') + ) + op.create_table('viewsettings', + sa.Column('op_id', sa.Integer(), nullable=False), + sa.Column('u_id', sa.Integer(), nullable=False), + sa.Column('settings', sa.JSON(), nullable=True), + sa.Column('created_at', cu.AwareDateTime(), nullable=True), + sa.Column('updated_at', cu.AwareDateTime(), nullable=True), + sa.ForeignKeyConstraint(['op_id'], ['operations.id'], name=op.f('fk_viewsettings_op_id_operations')), + sa.ForeignKeyConstraint(['u_id'], ['users.id'], name=op.f('fk_viewsettings_u_id_users')), + sa.PrimaryKeyConstraint('op_id', 'u_id', name=op.f('pk_viewsettings')), + sa.UniqueConstraint('u_id', 'op_id', name='u_id_op_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('viewsettings') + op.drop_table('sharedviews') + op.drop_table('manageviews') + # ### end Alembic commands ### diff --git a/mslib/mscolab/models.py b/mslib/mscolab/models.py index 32937750b..4d2c015c5 100644 --- a/mslib/mscolab/models.py +++ b/mslib/mscolab/models.py @@ -222,3 +222,58 @@ def __init__(self, op_id, u_id, commit_hash, version_name=None, comment=None): self.version_name = str(version_name) if comment is not None: self.comment = str(comment) + + +class ViewSettings(db.Model): + + __tablename__ = "viewsettings" + op_id = db.Column(db.Integer, db.ForeignKey('operations.id'), primary_key=True) + u_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True) + settings = db.Column(db.JSON, nullable=True) + created_at = db.Column(AwareDateTime, default=lambda: datetime.datetime.now(tz=datetime.timezone.utc)) + updated_at = db.Column(AwareDateTime, default=lambda: datetime.datetime.now(tz=datetime.timezone.utc), + onupdate=lambda: datetime.datetime.now(tz=datetime.timezone.utc)) + + user = db.relationship('User', backref='view_settings') + operation = db.relationship('Operation', backref='view_settings') + __table_args__ = (db.UniqueConstraint('u_id', 'op_id', name='u_id_op_id'),) + + def __init__(self, op_id, u_id, settings): + self.op_id = int(op_id) + self.u_id = int(u_id) + self.settings = settings + + +class SharedView(db.Model): + + __tablename__ = "sharedviews" + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 + op_id = db.Column(db.Integer, db.ForeignKey('operations.id'), nullable=False) + u_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + view_name = db.Column(db.String(255), nullable=False) + shared_data = db.Column(db.JSON, nullable=False) + created_at = db.Column(AwareDateTime, default=lambda: datetime.datetime.now(tz=datetime.timezone.utc)) + user = db.relationship('User', backref='shared_views') + __table_args__ = (db.UniqueConstraint('op_id', 'view_name', name='_op_view_uc'),) + + def __init__(self, op_id, u_id, view_name, shared_data): + self.op_id = int(op_id) + self.u_id = int(u_id) + self.view_name = str(view_name) + self.shared_data = shared_data + + +class ManageViews(db.Model): + + __tablename__ = "manageviews" + id = db.Column(db.Integer, primary_key=True, autoincrement=True) # noqa: A003 + op_id = db.Column(db.Integer, db.ForeignKey('operations.id'), nullable=False) + u_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + view_name = db.Column(db.String(255), nullable=False) + created_at = db.Column(AwareDateTime, default=lambda: datetime.datetime.now(tz=datetime.timezone.utc)) + user = db.relationship('User', backref='manage_views') + + def __init__(self, op_id, u_id, view_name): + self.op_id = int(op_id) + self.u_id = int(u_id) + self.view_name = str(view_name) diff --git a/mslib/mscolab/server.py b/mslib/mscolab/server.py index 1e5fef4e9..33dd265c9 100644 --- a/mslib/mscolab/server.py +++ b/mslib/mscolab/server.py @@ -872,6 +872,149 @@ def reset_request(): return render_template('errors/403.html'), 403 +@APP.route("/share_view", methods=["POST"]) +@verify_user +def share_view(): + try: + view_settings = request.form.get('view_settings', False) + view_name = request.form.get('view_name', None) + op_id = request.form.get('op_id') + user = g.user + if not all([view_settings, view_name, op_id]): + return jsonify({"success": False, "message": "Missing required parameters"}), 400 + if isinstance(view_settings, str): + settings_data = json.loads(view_settings) + else: + settings_data = view_settings + success, message = fm.share_view(op_id, view_name, user, settings_data) + if success: + return jsonify({"success": True, "message": message}), 200 + else: + return jsonify({"success": False, "message": message}), 400 + except json.JSONDecodeError as e: + logging.error("Invalid view_settings JSON: %s", str(e)) + return jsonify({"success": False, "message": "Invalid view_settings format"}), 400 + except Exception as e: + logging.error("Error in share_view endpoint: %s", str(e)) + return jsonify({"success": False, "message": f"Server error: {str(e)}"}), 500 + + +@APP.route("/get_shared_view", methods=["GET"]) +@verify_user +def get_shared_view(): + op_id = request.form.get('op_id') + view_name = request.form.get('view_name') + user = g.user + try: + success, message, settings = fm.get_shared_views(op_id, view_name, user) + if success: + return jsonify({"success": True, "message": message, "settings": settings}), 200 + else: + return jsonify({"success": False, "message": message, "settings": settings}), 400 + except Exception as e: + logging.error("Error in get_shared_view endpoint: %s", str(e)) + return jsonify({"success": False, "message": f"Server error: {str(e)}", "settings": {}}), 500 + + +@APP.route("/get_views_name", methods=["GET"]) +@verify_user +def get_views_name(): + op_id = request.args.get('op_id') + user = g.user + try: + success, message, views = fm.list_views(op_id, user) + if success: + return jsonify({"success": True, "message": message, "views": views}), 200 + else: + return jsonify({"success": False, "message": message, "views": views}), 400 + except Exception as e: + logging.error("Error listing views: %s", str(e)) + return jsonify({"success": False, "message": f"Error listing views: {str(e)}", "views": []}), 500 + + +@APP.route("/manage_sharedView_metadata", methods=["POST"]) +@verify_user +def manage_sharedView_metadata(): + view_name = request.form.get('view_name', None) + op_id = request.form.get('op_id', None) + user = g.user + if not all([view_name, op_id]): + return jsonify({"success": False, "message": "Missing required parameters"}), 400 + success, message = fm.save_manage_view_metadata(user, op_id, view_name) + if success: + return jsonify({"success": True, "message": message}), 200 + else: + return jsonify({"success": False, "message": message}), 400 + + +@APP.route("/check_view_names", methods=["GET"]) +@verify_user +def check_view_names(): + op_id = request.form.get('op_id') + view_name = request.form.get('view_name') + user = g.user + if not all([op_id is not None, view_name is not None]): + return jsonify({"success": False, "message": "Missing required parameters", "exists": False}), 400 + try: + exists = fm.check_view_name(user, op_id, view_name) + return jsonify({"success": True, "exists": exists}), 200 + except Exception as e: + logging.error("Error checking view name: %s", str(e)) + return jsonify({"success": False, "message": f"Error checking view name: {str(e)}", "exists": False}), 400 + + +@APP.route("/save_operation_view_settings", methods=["POST"]) +@verify_user +def save_operation_view_settings(): + view_settings = request.form.get('view_settings', False) + user = g.user + try: + try: + if isinstance(view_settings, str): + settings_data = json.loads(view_settings) + else: + settings_data = view_settings + except Exception as e: + logging.error("Invalid JSON: %s", str(e)) + return jsonify({"success": False, "message": "Invalid JSON"}), 400 + if not settings_data: + logging.error("No data provided in request") + return jsonify({"success": False, "message": "No data provided"}), 400 + + op_id = settings_data.get('global', {}).get("op_id", None) + if op_id is None: + logging.error("Missing operation ID in payload") + return jsonify({"success": False, "message": "Missing operation ID"}), 400 + + success, message = fm.save_view_settings(op_id, user, settings_data) + if success: + return jsonify({"success": True, "message": message}), 200 + else: + return jsonify({"success": False, "message": message}), 400 + + except Exception as e: + logging.error("Error saving view settings: %s", str(e)) + return jsonify({"success": False, "message": f"Failed to save settings: {str(e)}"}), 500 + + +@APP.route("/get_operation_view_settings", methods=["GET"]) +@verify_user +def get_operation_view_settings(): + op_id = request.form.get('op_id') + if op_id is None: + return jsonify({"success": False, "message": "Missing op_id parameter"}), 400 + + user = g.user + success, message, settings = fm.get_view_settings(op_id, user) + if success: + return jsonify({"success": True, "message": message, "settings": settings}), 200 + else: + ERROR_KEYWORDS = ("Access denied", "Missing", "Invalid settings", "Invalid JSON") + status_code = 400 if any(e in message for e in ERROR_KEYWORDS) else 500 + logging.error("Failed to retrieve view settings for user %s, operation %s: %s", user.id, op_id, message) + return jsonify({"success": False, "message": message, "settings": settings}), status_code + + if mscolab_settings.USE_SAML2: # setup idp login config setup_saml2_backend() diff --git a/mslib/msui/flighttrack.py b/mslib/msui/flighttrack.py index 745f1d8f7..754af4387 100644 --- a/mslib/msui/flighttrack.py +++ b/mslib/msui/flighttrack.py @@ -313,12 +313,12 @@ def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): # Return the names of the table columns. if orientation == QtCore.Qt.Horizontal: if self.performance_settings["visible"]: - return QtCore.QVariant(TABLE_FULL[section][0]) + return TABLE_FULL[section][0] else: - return QtCore.QVariant(TABLE_SHORT[section][0]) + return TABLE_SHORT[section][0] # Table rows (waypoints) are labelled with their number (= number of # waypoint). - return QtCore.QVariant(int(section)) + return section def rowCount(self, index=QtCore.QModelIndex()): """ @@ -680,7 +680,31 @@ def load_from_xml_data(self, xml_content, name="Flight track"): def get_filename(self): return self.filename + def column_index(self, column_name): + """Return the column index for a given column name.""" + for col in range(self.columnCount()): + header = self.headerData(col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole) + # unwrap QVariant if needed + # if isinstance(header, QtCore.QVariant): + # header = header.toString() + + if isinstance(header, str) and header.lower() == column_name.lower(): + return col + + raise ValueError(f"Column '{column_name}' not found") + + def get_waypoints_data(self): + """ + Return waypoints as a JSON-serializable list of dictionaries. + """ + return [{ + "location": wp.location, + "lat": float(wp.lat), + "lon": float(wp.lon), + "flightlevel": float(wp.flightlevel), + "comments": wp.comments + } for wp in self.waypoints] # # CLASS WaypointDelegate # diff --git a/mslib/msui/linearview.py b/mslib/msui/linearview.py index 739020286..e70df997e 100644 --- a/mslib/msui/linearview.py +++ b/mslib/msui/linearview.py @@ -25,6 +25,8 @@ limitations under the License. """ +import logging +import traceback from mslib.utils.config import config_loader from PyQt5 import QtGui, QtWidgets, QtCore from mslib.msui.qt5 import ui_linearview_window as ui @@ -221,6 +223,7 @@ def setFlightTrackModel(self, model): Set the QAbstractItemModel instance that the view displays. """ super().setFlightTrackModel(model) + self.active_flighttrack = model if self.docks[WMS] is not None: self.docks[WMS].widget().setFlightTrackModel(model) @@ -232,3 +235,189 @@ def open_settings_dialog(self): settings = dlg.get_settings() self.getView().plotter.set_settings(settings, save=True) dlg.destroy() + + def get_settings(self): + """Return a dictionary of all linear view settings.""" + # Get settings from the view (matplotlib canvas) + view_settings = self.getView().get_settings() + + # Get WMS settings + wms_settings = {} + if self.docks and self.docks[0] is not None: + wms_settings = { + "url": self.currurl, + "layer": self.currlayer, + "level": self.currlevel, + "styles": self.currstyles, + "flights": self.currflights, + "vertical": self.currvertical, + } + + # Get dock widget states + dock_states = [dock is not None for dock in self.docks] + + return { + "view_type": "linearview", + "plot_title_size": view_settings.get("plot_title_size", "10pt"), + "axes_label_size": view_settings.get("axes_label_size", "10pt"), + "wms": wms_settings, + "docks_open": dock_states, + } + + def set_settings(self, view): + """Restore Linear View settings from view_settings.json.""" + try: + if isinstance(view, list): + view = next((v for v in view if v.get("view_type") == "linearview"), {}) + + if not hasattr(self, 'docks') or not self.docks: + self.docks = [None, None] + + # Restore plot settings + plot_settings = { + "plot_title_size": str(view.get("plot_title_size", "10pt")), + "axes_label_size": str(view.get("axes_label_size", "10pt")), + "x_axis": view.get("x_axis", "distance"), + "y_axis": view.get("y_axis", "pressure"), + "y_extent": view.get("y_extent", [1000.0, 100.0]), + "line_thickness": view.get("line_thickness", 2.0), + "line_style": view.get("line_style", "Solid"), + "line_transparency": view.get("line_transparency", 1.0), + "colour_waypoints": view.get("colour_waypoints", [0, 0, 0, 1]), + "colour_path": view.get("colour_path", [0.5, 0.5, 0.5, 0.5]), + "draw_markers": view.get("draw_markers", True), + "label_waypoints": view.get("label_waypoints", True) + } + if hasattr(self, 'mpl') and self.mpl.canvas: + try: + self.mpl.canvas.plotter.set_settings(plot_settings, save=True) + except Exception as e: + logging.warning("Failed to restore plot settings: %s", str(e)) + + # Restore waypoints model + if hasattr(self, 'waypoints_model') and self.waypoints_model: + try: + self.setFlightTrackModel(self.waypoints_model) + except Exception as e: + logging.error( + "Error updating plotter from shared waypoints: %s\n%s", + str(e), traceback.format_exc() + ) + else: + logging.warning("waypoints_model not initialized; skipping waypoint redraw") + + # Restore dock visibility + docks_open = view.get("docks_open", [False] * len(self.docks)) + for idx, state in enumerate(docks_open): + if idx < len(self.docks): + if state and self.docks[idx] is None: + self.openTool(idx + 1) + elif self.docks[idx]: + self.docks[idx].setVisible(state) + + # Restore WMS settings + wms_settings = view.get("wms", {}) + if wms_settings: + if len(self.docks) <= WMS or self.docks[WMS] is None: + self.openTool(WMS + 1) + if self.docks[WMS]: + self.wms_control = self.docks[WMS].widget() + if isinstance(self.wms_control, wms.LSecWMSControlWidget): + self.restore_wms_settings(wms_settings) + else: + logging.warning( + "WMS control widget not available; got %s", type(self.wms_control) + ) + else: + logging.warning("WMS dock not initialized") + else: + logging.debug("No WMS settings provided; skipping WMS restoration") + + # Redraw canvas + if hasattr(self, 'mpl') and self.mpl.canvas: + self.mpl.canvas.draw() + + except Exception as e: + logging.error("Error in set_settings: %s\n%s", str(e), traceback.format_exc()) + + def restore_wms_settings(self, wms): + """Restore WMS settings into the existing WMS control widget (Linear View).""" + if self.wms_control is None: + logging.warning("Cannot restore WMS settings: wms_control does not exist") + return + + try: + url = wms.get("url", "") + layer = wms.get("layer", "") + level = wms.get("level", "") + styles = wms.get("styles", "") + init_time = wms.get("init_time", "") + valid_time = wms.get("valid_time", "") + + if not url: + logging.info("No WMS URL provided") + return + + # Initialize WMS and update attributes + self.wms_control.initialise_wms(url, level=level or "") + self.currurl = url + self.currlayer = layer + self.currlevel = level + self.currstyles = styles + self.currvtime = QtCore.QDateTime.fromString(init_time, QtCore.Qt.ISODate) \ + if init_time else QtCore.QDateTime.currentDateTimeUtc() + self.currvalidtime = QtCore.QDateTime.fromString(valid_time, QtCore.Qt.ISODate) \ + if valid_time else QtCore.QDateTime.currentDateTimeUtc() + + # Update combobox + wms_url_combo = getattr(self.wms_control.multilayers, 'cbWMS_URL', None) + if wms_url_combo: + wms_url_combo.setCurrentText(url) + wms_url_combo.currentTextChanged.emit(url) + QtCore.QCoreApplication.processEvents() + else: + logging.error("WMS URL combobox 'cbWMS_URL' not found") + return + + # Select layer and style + if hasattr(self.wms_control.multilayers, 'listLayers'): + self.wms_control.select_layer_and_style( + self.wms_control.multilayers.listLayers, layer, styles + ) + else: + logging.warning("listLayers not found; skipping layer selection") + + # Select row if waypoints_model exists + if hasattr(self, 'waypoints_model') and self.waypoints_model: + self.wms_control.row_is_selected(url, layer, styles, level, "linear") + else: + logging.debug("Skipping row_is_selected due to missing waypoints_model") + + # Restore init time + if init_time and hasattr(self.wms_control, 'cbInitTime'): + idx = self.wms_control.cbInitTime.findText(init_time) + if idx >= 0: + self.wms_control.cbInitTime.setCurrentIndex(idx) + else: + logging.warning("Init time %s not found in combo box", init_time) + + # Restore valid time + if valid_time and hasattr(self.wms_control, 'cbValidTime'): + idx = self.wms_control.cbValidTime.findText(valid_time) + if idx >= 0: + self.wms_control.cbValidTime.setCurrentIndex(idx) + self.wms_control.leftrow_is_selected(valid_time) + else: + logging.warning("Valid time %s not found in combo box", valid_time) + + # Final layer selection & canvas update + target_layer_item = self.wms_control.find_layer_item_by_name(layer) + if target_layer_item: + self.wms_control.multilayers.current_layer = target_layer_item + self.wms_control.call_get_lsec() + self.wms_connected = True + if hasattr(self, 'mpl') and self.mpl.canvas: + self.mpl.canvas.redraw_map() + + except Exception as e: + logging.error("Error restoring WMS settings: %s\n%s", str(e), traceback.format_exc()) diff --git a/mslib/msui/mpl_qtwidget.py b/mslib/msui/mpl_qtwidget.py index 0d5ceeaef..f8b354530 100644 --- a/mslib/msui/mpl_qtwidget.py +++ b/mslib/msui/mpl_qtwidget.py @@ -504,11 +504,11 @@ def redraw_yaxis(self): # Sets fontsize value for x axis ticklabel. axes_label_size = (self.sideview_size_settings["axes_label_size"] if self.settings["axes_label_size"] == "default" - else int(self.settings["axes_label_size"])) + else int(self.settings["axes_label_size"].replace("pt", "").strip())) # Sets fontsize value for plot title and axes title/label plot_title_size = (self.sideview_size_settings["plot_title_size"] if self.settings["plot_title_size"] == "default" - else int(self.settings["plot_title_size"])) + else int(self.settings["plot_title_size"].replace("pt", "").strip())) # Updates the fontsize of the x-axis ticklabels of sideview. self.ax.tick_params(axis='x', labelsize=axes_label_size) # Updates the fontsize of plot title and x-axis title of sideview. @@ -784,9 +784,9 @@ def set_settings(self, settings, save=False): super().set_settings(settings, save) pts = (self.linearview_size_settings["plot_title_size"] if self.settings["plot_title_size"] == "default" - else int(self.settings["plot_title_size"])) + else int(self.settings["plot_title_size"].replace("pt", "").strip())) label_size = (self.linearview_size_settings["axes_label_size"] if self.settings["axes_label_size"] == "default" - else int(self.settings["axes_label_size"])) + else int(self.settings["axes_label_size"].replace("pt", "").strip())) self.ax.tick_params(axis='both', labelsize=label_size) self.ax.set_title("Linear flight profile", fontsize=pts, horizontalalignment='left', x=0) self.ax.figure.canvas.draw() diff --git a/mslib/msui/mscolab.py b/mslib/msui/mscolab.py index 78efe96db..37cf27429 100644 --- a/mslib/msui/mscolab.py +++ b/mslib/msui/mscolab.py @@ -69,6 +69,7 @@ from mslib.msui.qt5 import ui_mscolab_connect_dialog as ui_conn from mslib.msui.qt5 import ui_mscolab_profile_dialog as ui_profile from mslib.msui.qt5 import ui_operation_archive as ui_opar +from mslib.msui.qt5 import ui_manageView_dialog as ui_manage_view from mslib.msui import constants from mslib.utils.config import config_loader, modify_config_file @@ -328,6 +329,7 @@ def connect_handler(self): self.set_status("Error", "Some unexpected error occurred. Please try again.") def disconnect_handler(self): + self.mscolab.close_external_windows() self.urlCb.setEnabled(True) # enable/disable appropriate widgets in login frame @@ -493,6 +495,66 @@ def new_user_handler(self): self.set_status("Error", error_msg) +class ManageViewDialog(QtWidgets.QDialog): + def __init__(self, mscolab, parent=None): + super().__init__(parent) + self.ui = ui_manage_view.Ui_Form() + self.ui.setupUi(self) + self.ui.listView.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) + self.setModal(False) + self.setWindowFlags(QtCore.Qt.Dialog | QtCore.Qt.WindowCloseButtonHint) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) + self.mscolab = mscolab + self.setWindowTitle("Manage Views") + self.model = QtGui.QStandardItemModel() + self.model = QtGui.QStandardItemModel() + self.ui.listView.setModel(self.model) + + self.ui.pushButton.clicked.connect(self.apply_selected_view) + self.refresh_shared_views() + + def apply_selected_view(self): + """Apply settings for selected views in listView.""" + selected_indexes = self.ui.listView.selectedIndexes() + if not selected_indexes: + QtWidgets.QMessageBox.warning(self, "No Selection", "Please select at least one shared view to apply.") + return + index = selected_indexes[0] + for index in selected_indexes: + item = self.ui.listView.model().itemFromIndex(index) + if not item: + continue + data = item.data(QtCore.Qt.UserRole) + view_name = data['view_name'] + op_id = data['op_id'] + view_settings = self.mscolab.get_sharedView_settings(view_name, op_id) + view_type = view_settings.get("view_type") + if view_settings is None: + QtWidgets.QMessageBox.warning(self, "Error", + f"Failed to retrieve settings for {view_name} by user {data['username']}") + continue + self.mscolab.ui.create_view(view_type, model=self.mscolab.waypoints_model, restore_settings=view_settings) + item = QtWidgets.QListWidgetItem(view_name) + self.ui.listWidget.addItem(item) + + def refresh_shared_views(self): + url = urljoin(self.mscolab.mscolab_server_url, "get_views_name") + data = {"op_id": self.mscolab.active_op_id, "token": self.mscolab.token} + if hasattr(self.mscolab, 'auth') and self.mscolab.auth: + response = requests.get(url, params=data, auth=self.mscolab.auth, timeout=(5, 30)) + else: + response = requests.get(url, params=data, timeout=(5, 30)) + response.raise_for_status() + views = response.json().get("views", []) + self.model.clear() + for view in views: + item = QtGui.QStandardItem(f"{view['view_name']} (by {view['username']})") + item.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) + item.setData(view, QtCore.Qt.UserRole) + self.model.appendRow(item) + return True + + class MSUIMscolab(QtCore.QObject): """ Class for implementing MSColab functionalities @@ -535,6 +597,7 @@ def __init__(self, parent=None, local_operations_data=None): # connect operation options menu actions self.ui.actionAddOperation.triggered.connect(self.add_operation_handler) + self.ui.actionOpenManageView.triggered.connect(self.open_manage_view_widget) self.ui.actionChat.triggered.connect(self.operation_options_handler) self.ui.actionVersionHistory.triggered.connect(self.operation_options_handler) self.ui.actionManageUsers.triggered.connect(self.operation_options_handler) @@ -604,6 +667,8 @@ def __init__(self, parent=None, local_operations_data=None): # Gravatar image path self.gravatar = None + self.manage_view_widget = None + # Service message text for flight-track changes (waypoints inserted, moved or deleted) self.lastChangeMessage = "" @@ -614,6 +679,22 @@ def __init__(self, parent=None, local_operations_data=None): self.data_dir = Path(local_operations_data) self.create_dir() + def open_manage_view_widget(self): + if self.manage_view_widget is not None and self.manage_view_widget.isVisible(): + # If the widget is already open, raise and activate it + self.manage_view_widget.raise_() + self.manage_view_widget.activateWindow() + else: + # Create a new widget if none exists or the existing one is closed + self.manage_view_widget = ManageViewDialog(self, self.ui) + self.manage_view_widget.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) # Ensure cleanup on close + self.manage_view_widget.destroyed.connect(self.close_manage_view_widget) # Connect to cleanup slot + self.manage_view_widget.show() + + def close_manage_view_widget(self): + # Clean up the reference when the widget is closed + self.manage_view_widget = None + def _handle_font_bolding(self, item=None): font = QtGui.QFont() for i in range(self.ui.listOperationsMSC.count()): @@ -1179,6 +1260,9 @@ def close_external_windows(self): if self.version_window is not None: self.version_window.close() self.version_window = None + if self.manage_view_widget is not None: + self.manage_view_widget.close() + self.manage_view_widget = None @verify_user_token def handle_delete_operation(self): @@ -1349,6 +1433,7 @@ def rename_operation_handler(self, _=None): @verify_user_token def handle_work_locally_toggle(self, _=None): if self.ui.workLocallyCheckbox.isChecked(): + self.send_view_settings_to_server() if self.version_window is not None: self.version_window.close() self.create_local_operation_file() @@ -1427,6 +1512,140 @@ def server_options_handler(self, index): elif selected_option == "Save To Server": self.save_wp_mscolab() + def get_views(self, view_name): + data = { + "op_id": self.active_op_id, + "view_name": view_name, + "token": self.token + } + response = self.conn.request_get("check_view_names", data=data) + return response.json() + + @verify_user_token + def manage_view_metadata(self, op_id, view_name): + if not self.active_op_id or not self.token: + return None + data = { + "op_id": op_id, + "view_name": view_name, + "token": self.token + } + try: + response = self.conn.request_post("manage_sharedView_metadata", data=data) + response.raise_for_status() + if self.manage_view_widget is not None: + self.manage_view_widget.refresh_shared_views() + except requests.exceptions.RequestException as ex: + logging.error("Error fetching view metadata for %s: %s", view_name, ex) + return None + + @verify_user_token + def share_view_settings(self, settings_list, view_name): + """Send view settings to the MSColab server for sharing.""" + if not self.active_op_id or not self.token: + return False + op_id = self.active_op_id + data = { + "op_id": op_id, + "view_name": view_name, + "view_settings": json.dumps(settings_list), + "token": self.token + } + try: + response = self.conn.request_post("share_view", data=data) + response.raise_for_status() + self.manage_view_metadata(op_id=op_id, view_name=view_name) + except requests.exceptions.RequestException as ex: + logging.error("Error sharing view %s: %s", view_name, ex) + + def get_sharedView_settings(self, view_name, op_id): + """Retrieve settings for a specific view from the server.""" + if not self.active_op_id or not self.token: + logging.error("Cannot fetch view settings: No active operation or token") + return None + data = { + 'op_id': op_id, + 'view_name': view_name, + 'token': self.token + } + try: + response = self.conn.request_get('get_shared_view', data=data) + response.raise_for_status() + data = response.json() + if data.get("success"): + settings = data.get("settings", {}) + if settings: + return settings + else: + logging.error("No settings found for view %s", view_name) + return None + else: + logging.error("Failed to fetch view settings: %s", data.get("message", "Unknown error")) + return None + except requests.exceptions.RequestException as ex: + logging.error("Error fetching view settings for %s: %s", view_name, ex) + return None + + @verify_user_token + def save_operation_view_settings(self, settings): + """Save collected settings for the active operation to the server.""" + data = { + "operation_name": self.active_operation_name, + "view_settings": json.dumps(settings), + "token": self.token + } + response = self.conn.request_post("save_operation_view_settings", data=data) + try: + response.raise_for_status() + try: + if not response.text.strip(): # empty response body + logging.info("Empty response body from server! status=%s", response.status_code) + return + + except requests.exceptions.JSONDecodeError: + logging.error("Response not valid JSON! text=%s", response.text) + return + except requests.exceptions.RequestException as ex: + logging.error("Request error: %s", ex) + raise + + def send_view_settings_to_server(self, op_id=None): + """Orchestrate saving view settings for the specified or active operation.""" + if self.ui.local_active or not self.active_op_id: + return + if op_id is not None and op_id != self.active_op_id: + logging.warning("Mismatched op_id=%s, active_op_id=%s; using active_op_id", op_id, self.active_op_id) + op_id = self.active_op_id + settings = self.ui.get_operation_view_settings() + if not settings["views"]: + logging.warning("No view settings to send for op_id=%s", self.active_op_id) + return + self.save_operation_view_settings(settings) + + def load_operation_view_settings(self): + """Fetch view settings for the active operation from the server.""" + if not self.mscolab_server_url or not self.token or not self.active_op_id: + logging.warning("Cannot fetch settings: Invalid MSColab context (server_url=%s, token=%s, op_id=%s)", + self.mscolab_server_url, self.token, self.active_op_id) + return None + try: + data = { + "op_id": self.active_op_id + } + response = self.conn.request_get("get_operation_view_settings", data=data) + try: + response_data = response.json() + except requests.exceptions.JSONDecodeError: + logging.error("Response not valid JSON! text=%s", response.text) + return + settings = response_data.get("settings") + self.ui.create_operation_view_settings(settings) + return settings + + except requests.exceptions.RequestException as e: + logging.error("Failed to fetch settings: %s", str(e)) + return None + @verify_user_token def fetch_wp_mscolab(self): server_xml = self.request_wps_from_server() @@ -1731,6 +1950,7 @@ def archive_operation(self, _): self.tr(f"Do you want to archive this operation '{self.active_operation_name}'?"), QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if ret == QMessageBox.Yes: + self.send_view_settings_to_server() try: response = self.conn.request_post( "update_operation", @@ -1747,9 +1967,10 @@ def archive_operation(self, _): @verify_user_token def set_active_op_id(self, item): - logging.debug('set_active_op_id %s %s %s', item, item.op_id, self.active_op_id) if not self.ui.local_active and item.op_id == self.active_op_id: return + self.send_view_settings_to_server() + self.ui.save_view_settings() # close all hanging window self.close_external_windows() @@ -1786,6 +2007,9 @@ def set_active_op_id(self, item): # change font style for selected self._handle_font_bolding(item) + restore_views = config_loader(dataset="restore_views", default=False) + if restore_views: + self.load_operation_view_settings() # set new waypoints model to open views for window in self.ui.get_active_views(): @@ -1795,6 +2019,10 @@ def set_active_op_id(self, item): else: window.enable_navbar_action_buttons() + # Refresh ManageViewDialog if it is open + if self.manage_view_widget is not None and self.manage_view_widget.isVisible(): + self.manage_view_widget.refresh_shared_views() + self.ui.switch_to_mscolab() # Enable the active user count label @@ -1804,6 +2032,7 @@ def set_active_op_id(self, item): self.conn.select_operation(item.op_id) def switch_to_local(self): + self.send_view_settings_to_server() logging.debug('switch_to_local') self.ui.local_active = True if self.active_op_id is not None: @@ -1826,6 +2055,7 @@ def show_operation_options(self): self.ui.actionChangeDescription.setEnabled(False) self.ui.actionArchiveOperation.setEnabled(False) self.ui.actionViewDescription.setEnabled(True) + self.ui.actionOpenManageView.setEnabled(False) self.ui.menuProperties.setEnabled(True) if self.access_level == "viewer": @@ -1835,9 +2065,13 @@ def show_operation_options(self): if self.access_level in ["creator", "admin", "collaborator"]: if self.ui.workLocallyCheckbox.isChecked(): self.ui.actionChat.setEnabled(True) + self.ui.actionOpenManageView.setEnabled(False) + self.ui.shareViewGroupBox.setEnabled(False) else: self.ui.actionChat.setEnabled(True) self.ui.actionVersionHistory.setEnabled(True) + self.ui.actionOpenManageView.setEnabled(True) + self.ui.shareViewGroupBox.setEnabled(True) self.ui.workLocallyCheckbox.setEnabled(True) else: if self.version_window is not None: @@ -1875,6 +2109,7 @@ def hide_operation_options(self): self.ui.actionChangeCategory.setEnabled(False) self.ui.actionChangeDescription.setEnabled(False) self.ui.actionDeleteOperation.setEnabled(False) + self.ui.actionOpenManageView.setEnabled(False) self.ui.workLocallyCheckbox.setEnabled(False) self.ui.menuProperties.setEnabled(False) self.ui.serverOptionsCb.hide() @@ -2014,6 +2249,7 @@ def logout(self): logging.debug('logout') if self.mscolab_server_url is None: return + self.send_view_settings_to_server() self.ui.local_active = True self.ui.menu_handler() diff --git a/mslib/msui/msui_mainwindow.py b/mslib/msui/msui_mainwindow.py index 47106b042..af781221b 100644 --- a/mslib/msui/msui_mainwindow.py +++ b/mslib/msui/msui_mainwindow.py @@ -52,6 +52,7 @@ from mslib.utils.config import read_config_file, config_loader from PyQt5 import QtGui, QtCore, QtWidgets from mslib.utils import release_info +from mslib.utils import view_restoration from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas @@ -405,6 +406,50 @@ def __init__(self, parent=None): self.lblPython.setPixmap(blub) +class ViewListModel(QtCore.QAbstractListModel): + def __init__(self, views, parent=None): + super().__init__(parent) + self.views = views + + def rowCount(self, parent=QtCore.QModelIndex()): + return len(self.views) + + def data(self, index, role=QtCore.Qt.DisplayRole): + if not index.isValid() or index.row() >= len(self.views): + return None + if role == QtCore.Qt.DisplayRole: + return self.views[index.row()][0] + elif role == QtCore.Qt.UserRole: + return self.views[index.row()][1] + return None + + def setData(self, index, value, role=QtCore.Qt.EditRole): + """Handle changes to the item data when edited.""" + if not index.isValid() or role != QtCore.Qt.EditRole: + return False + if value.strip() == "": + return False # Prevent empty names + self.views[index.row()] = (value, self.views[index.row()][1]) + self.dataChanged.emit(index, index, [QtCore.Qt.DisplayRole]) + return True + + def flags(self, index): + """Enable items to be editable.""" + if not index.isValid(): + return QtCore.Qt.NoItemFlags + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEditable + + def add_view(self, text, window): + self.beginInsertRows(QtCore.QModelIndex(), len(self.views), len(self.views)) + self.views.append((text, window)) + self.endInsertRows() + + def clear(self): + self.beginResetModel() + self.views = [] + self.endResetModel() + + class MSUIMainWindow(QtWidgets.QMainWindow, ui.Ui_MSUIMainWindow): """MSUI new main window class. Provides user interface elements for managing flight tracks, views and MSColab functionalities. @@ -450,6 +495,18 @@ def __init__(self, local_operations_data=None, tutorial_mode=False, *args): self.config_editor = None self.local_active = True self.new_flight_track_counter = 0 + self.flight_track_settings = {} # Real-time settings dictionary + self.activated_flight_tracks = set() + + self.listView.setEditTriggers(QtWidgets.QAbstractItemView.DoubleClicked) + self.viewListModel = ViewListModel([], self) + self.listView.setModel(self.viewListModel) + self.listView.selectionModel().selectionChanged.connect(self.handle_share_view_selection) + self.listView.model().dataChanged.connect(self.handle_view_name_changed) + self.pushButton.clicked.connect(self.handle_share_button) + self.listViews.itemDoubleClicked.connect(self.update_share_view_list) + self.viewsChanged.connect(self.update_view_list_on_change) + self.pushButton.setEnabled(False) # Reference to the flight track that is currently displayed in the views. self.active_flight_track = None @@ -513,6 +570,8 @@ def __init__(self, local_operations_data=None, tutorial_mode=False, *args): # Create MSColab instance to handle all MSColab functionalities self.mscolab = mscolab.MSUIMscolab(parent=self, local_operations_data=local_operations_data) + # Create manage view dialog instance + # self.mangeView = mscolab.ManageViewDialog(parent=self) # Setting up MSColab Tab self.connectBtn.clicked.connect(self.mscolab.open_connect_window) @@ -527,6 +586,8 @@ def __init__(self, local_operations_data=None, tutorial_mode=False, *args): lambda: self.listFlightTracks.setCurrentItem(None)) # disable category until connected/login into mscolab self.filterCategoryCb.setEnabled(False) + self.actionOpenManageView.setEnabled(False) + self.shareViewGroupBox.setEnabled(False) self.mscolab.signal_unarchive_operation.connect(self.activate_operation_slot) self.mscolab.signal_operation_added.connect(self.add_operation_slot) self.mscolab.signal_operation_removed.connect(self.remove_operation_slot) @@ -540,6 +601,120 @@ def __init__(self, local_operations_data=None, tutorial_mode=False, *args): self.openOperationsGb.hide() + def handle_view_name_changed(self, topLeft, bottomRight, roles): + """Handle changes to the view name when edited in the listView widget.""" + if QtCore.Qt.DisplayRole not in roles: + return + index = topLeft + if not index.isValid(): + return + new_name = self.viewListModel.data(index, QtCore.Qt.DisplayRole) + window = self.viewListModel.data(index, QtCore.Qt.UserRole) + if window and new_name: + current_title = window.windowTitle() + id_part = current_title.split(") ")[0] + ")" + window.setWindowTitle(f"{id_part} {new_name} - {window.waypoints_model.name}") + window.setIdentifier(new_name) + + def update_share_view_list(self, item): + """Append the double-clicked view from listViews to listView.""" + if item and item.window.isVisible(): + view_window = item.window + view_name = item.text() + for i in range(self.viewListModel.rowCount()): + existing_window = self.viewListModel.data(self.viewListModel.index(i), QtCore.Qt.UserRole) + if existing_window == view_window or existing_window.identifier == view_window.identifier: + view_window.showNormal() + view_window.raise_() + view_window.activateWindow() + return + self.viewListModel.add_view(view_name, view_window) + self.pushButton.setEnabled(True) + + def update_view_list_on_change(self): + """Remove closed views from listView and update button state.""" + if not self.viewListModel.views: + self.pushButton.setEnabled(False) + return + # Keep only visible views + valid_views = [(text, window) for text, window in self.viewListModel.views if window.isVisible()] + if len(valid_views) != len(self.viewListModel.views): + self.viewListModel.clear() + for text, window in valid_views: + self.viewListModel.add_view(text, window) + self.pushButton.setEnabled(bool(self.viewListModel.views)) + + def handle_share_view_selection(self, selected, deselected): + """Handle selection in listView to access the window instances.""" + indexes = self.listView.selectionModel().selectedIndexes() + for index in indexes: + window = self.viewListModel.data(index, QtCore.Qt.UserRole) + if window: + logging.info("Selected shared view: %s (type: %s)", window.windowTitle(), window.view_type) + window.showNormal() + window.raise_() + window.activateWindow() + + def handle_share_button(self): + if not self.viewListModel.views: + QtWidgets.QMessageBox.warning(self, "No Views", "No views available to share.") + return + + shared_views = [] + failed_views = [] + indices_to_remove = [] + + for i in range(self.viewListModel.rowCount()): + view_name = self.viewListModel.data(self.viewListModel.index(i), QtCore.Qt.DisplayRole) + window = self.viewListModel.data(self.viewListModel.index(i), QtCore.Qt.UserRole) + if not view_name: + failed_views.append((view_name or "Unnamed View", "View name cannot be empty.")) + continue + result = self.mscolab.get_views(view_name) + if not result["success"]: + failed_views.append((view_name, result["message"])) + continue + if result["exists"]: + failed_views.append((view_name, + f"View name '{view_name}' already exists. Please choose a different name.")) + continue + try: + view_settings = window.get_settings() + if not isinstance(view_settings, dict): + failed_views.append((view_name, "Invalid view settings.")) + continue + self.mscolab.share_view_settings(view_settings, view_name) + shared_views.append(view_name) + indices_to_remove.append(i) + except Exception as ex: + logging.error("Failed to share view %s: %s", view_name, ex) + failed_views.append((view_name, f"Failed to share: {str(ex)}")) + + indices_to_remove.sort(reverse=True) + + for index in indices_to_remove: + self.viewListModel.beginRemoveRows(QtCore.QModelIndex(), index, index) + self.viewListModel.views.pop(index) + self.viewListModel.endRemoveRows() + + self.pushButton.setEnabled(bool(self.viewListModel.views)) + + message = [] + if shared_views: + message.append(f"Successfully shared and removed {len(shared_views)} view(s): {', '.join(shared_views)}") + if failed_views: + message.append("Failed to share the following view(s):") + for view_name, reason in failed_views: + message.append(f"- {view_name}: {reason}") + if message: + QtWidgets.QMessageBox.information( + self, + "Share Views", + "\n".join(message) + ) + else: + QtWidgets.QMessageBox.information(self, "Share Views", "No views were processed.") + def bring_main_window_to_front(self): self.show() self.raise_() @@ -704,13 +879,13 @@ def handle_import_local(self, extension, function, pickertype): def handle_export_local(self, extension, function, pickertype): if self.local_active: - default_filename = f'{Path(self.last_save_directory) / self.active_flight_track.name}.{extension}' + default_filename = f'{os.path.join(self.last_save_directory, self.active_flight_track.name)}.{extension}' filename = get_save_filename( self, "Export Flight Track", - str(default_filename), f"Flight Track (*.{extension})", + default_filename, f"Flight Track (*.{extension})", pickertype=pickertype) if filename is not None: - self.last_save_directory = str(Path(filename).parent) + self.last_save_directory = Path(filename).parent try: if function is None: doc = self.active_flight_track.get_xml_doc() @@ -833,7 +1008,7 @@ def create_new_flight_track(self, template=None, filename=None, function=None, a # function is none if ftml file is selected if function is None: try: - waypoints_model = ft.WaypointsTableModel(filename=str(filename)) + waypoints_model = ft.WaypointsTableModel(filename=filename) except (SyntaxError, OSError, IOError) as ex: QtWidgets.QMessageBox.critical( self, self.tr("Problem while opening flight track FTML:"), @@ -869,6 +1044,10 @@ def create_new_flight_track(self, template=None, filename=None, function=None, a listitem = QFlightTrackListWidgetItem(waypoints_model, self.listFlightTracks) listitem.setFlags(QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) + # Update settings dictionary for new flight track + self.update_flight_track_settings(waypoints_model) + self.mscolab.send_view_settings_to_server() + # Activate new item if activate: self.activate_flight_track(listitem) @@ -879,8 +1058,16 @@ def activate_flight_track(self, item): displayed at a time). """ self.mscolab.switch_to_local() + if not config_loader(dataset="restore_views"): + for i in range(self.listViews.count()): + view_item = self.listViews.item(i) + view_window = view_item.window + if hasattr(view_window, 'active_flighttrack') and view_window.active_flighttrack: + self.update_flight_track_settings(view_window.active_flighttrack, view_window) + # self.setWindowModality(QtCore.Qt.NonModal) self.active_flight_track = item.flighttrack_model + self.activated_flight_tracks.add(self.active_flight_track.name) self.update_active_flight_track() font = QtGui.QFont() for i in range(self.listFlightTracks.count()): @@ -891,16 +1078,29 @@ def activate_flight_track(self, item): self.menu_handler() self.signal_activate_flighttrack.emit(self.active_flight_track) + restore_views = config_loader(dataset="restore_views", default=False) + if restore_views: + while self.listViews.count() > 0: + self.listViews.item(0).window.handle_force_close() + self.listViews.clear() + # Remove the item from the list + self.viewsChanged.emit() + QActiveViewsListWidgetItem.opened_views = 0 + self.restore_views_for_active_flighttrack() + def update_active_flight_track(self, old_flight_track_name=None): - logging.debug("update_active_flight_track") - for i in range(self.listViews.count()): - view_item = self.listViews.item(i) - view_item.window.setFlightTrackModel(self.active_flight_track) - # local we have always all options enabled - view_item.window.enable_navbar_action_buttons() - if old_flight_track_name is not None: - view_item.window.setWindowTitle(view_item.window.windowTitle().replace(old_flight_track_name, - self.active_flight_track.name)) + if not config_loader(dataset="restore_views"): + for i in range(self.listViews.count()): + view_item = self.listViews.item(i) + view_item.window.setFlightTrackModel(self.active_flight_track) + view = view_item.window + # Update the flight track model and active_flighttrack + view.setFlightTrackModel(self.active_flight_track) + view.active_flighttrack = self.active_flight_track + view_item.window.enable_navbar_action_buttons() + if old_flight_track_name is not None: + view.setWindowTitle(view.windowTitle().replace(old_flight_track_name, + self.active_flight_track.name)) def activate_selected_flight_track(self): item = self.listFlightTracks.currentItem() @@ -987,6 +1187,7 @@ def close_selected_flight_track(self): QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) if ret == QtWidgets.QMessageBox.Yes: + self.update_flight_track_settings(item.flighttrack_model, remove=True) self.listFlightTracks.takeItem(self.listFlightTracks.currentRow()) def create_view_handler(self, _type): @@ -1000,7 +1201,53 @@ def create_view_handler(self, _type): # can happen, when the servers secret was changed show_popup(self.mscolab.ui, "Error", "Session expired, new login required") - def create_view(self, _type, model): + def restore_views_for_active_flighttrack(self): + if not (self.active_flight_track or self.mscolab.active_op_id): + logging.warning("No active flight track to restore views for") + return + + restored_data = view_restoration.restore_view_settings(self.active_flight_track.name) + if restored_data is None: + return + if not isinstance(restored_data, dict): + logging.error("Invalid restore data for flight track %s", self.active_flight_track.name) + return + + global_data = restored_data.get("global") + saved_flighttrack_name = global_data.get("flight_track_name") + if saved_flighttrack_name and saved_flighttrack_name != self.active_flight_track.name: + return + + # Extract views to restore + views_to_restore = restored_data.get("views", []) + existing_views = {} + for i in range(self.listViews.count()): + view_item = self.listViews.item(i) + view_window = view_item.window + if hasattr(view_window, 'view_type'): + existing_views[view_window.view_type] = view_window + + for view_setting in views_to_restore: + view_type = view_setting.get("view_type") + if not view_type: + logging.warning("Skipping view with missing view_type: %s", view_setting) + continue + + # Check if a view of this type already exists + existing_view = existing_views.get(view_type) + if existing_view: + # Update existing view's flight track and settings + existing_view.setFlightTrackModel(self.active_flight_track) + existing_view.active_flighttrack = self.active_flight_track + existing_view.set_settings([view_setting], global_data) + identifier_prefix = existing_view.identifier.split(') ')[0] + title = f"({identifier_prefix}) {view_type.capitalize()} - {self.active_flight_track.name}" + existing_view.setWindowTitle(title) + else: + self.create_view(view_type, self.active_flight_track, + restore_settings=[view_setting]) + + def create_view(self, _type, model, restore_settings=None): """Method called when the user selects a new view to be opened. Creates a new instance of the view and adds a QActiveViewsListWidgetItem to the list of open views (self.listViews). @@ -1032,6 +1279,9 @@ def create_view(self, _type, model): view_window.mpl.resize(layout['topview'][0], layout['topview'][1]) if layout["immutable"]: view_window.mpl.setFixedSize(layout['topview'][0], layout['topview'][1]) + if restore_settings: + view_window.set_settings(restore_settings) + elif _type == "sideview": # Side view. view_window = sideview.MSUISideViewWindow(mainwindow=self, model=model, tutorial_mode=self.tutorial_mode, @@ -1040,10 +1290,15 @@ def create_view(self, _type, model): view_window.mpl.resize(layout['sideview'][0], layout['sideview'][1]) if layout["immutable"]: view_window.mpl.setFixedSize(layout['sideview'][0], layout['sideview'][1]) + if restore_settings: + view_window.set_settings(restore_settings) + elif _type == "tableview": # Table view. view_window = tableview.MSUITableViewWindow(model=model, tutorial_mode=self.tutorial_mode) view_window.centralwidget.resize(layout['tableview'][0], layout['tableview'][1]) + if restore_settings: + view_window.set_settings(restore_settings) elif _type == "linearview": # Linear view. view_window = linearview.MSUILinearViewWindow(mainwindow=self, model=model, @@ -1053,6 +1308,8 @@ def create_view(self, _type, model): view_window.mpl.resize(layout['linearview'][0], layout['linearview'][1]) if layout["immutable"]: view_window.mpl.setFixedSize(layout['linearview'][0], layout['linearview'][1]) + if restore_settings: + view_window.set_settings(restore_settings) if view_window is not None: # Set view type to window @@ -1066,6 +1323,8 @@ def create_view(self, _type, model): # listitem = QActiveViewsListWidgetItem(view_window, self.listViews, self.viewsChanged, mscolab) listitem = QActiveViewsListWidgetItem(view_window, self.listViews, self.viewsChanged) view_window.viewCloses.connect(listitem.view_destroyed) + view_window.viewCloses.connect(lambda: self.update_flight_track_settings(self.active_flight_track, + view_window, remove=True)) self.listViews.setCurrentItem(listitem) # self.active_view_windows.append(view_window) # disable navbar actions in the view for viewer @@ -1182,6 +1441,150 @@ def status(self): else: return (f"Status : User Configuration '{constants.MSUI_SETTINGS}' loaded") + def update_flight_track_settings(self, flight_track, view=None, remove=False): + """Update the flight_track_settings dictionary when a flight track or view is created, modified, or removed.""" + if not flight_track or not view_restoration.is_flight_track_stored(flight_track): + return + if not self.active_flight_track or flight_track.name != self.active_flight_track.name: + return + + json_key = flight_track.name + if remove: + self.flight_track_settings.pop(json_key, None) + self.activated_flight_tracks.discard(json_key) + return + + if json_key not in self.flight_track_settings: + self.flight_track_settings[json_key] = {"views": [], "global": {}} + self.activated_flight_tracks.add(json_key) + + if view: + if hasattr(view, 'active_flighttrack') and view.active_flighttrack.name != self.active_flight_track.name: + return + view_type = getattr(view, 'view_type', type(view).__name__).lower().replace(" ", "") + try: + if remove: # Remove the view's settings + view_id = getattr(view, 'view_id', None) + if view_id: + self.flight_track_settings[json_key]["views"] = [ + setting for setting in self.flight_track_settings[json_key]["views"] + if setting.get("view_id") != view_id + ] + else: + settings = view.get_settings() + if not isinstance(settings, dict): + logging.warning("Invalid settings from view %s (type: %s, id: %s), skipping: %s", + getattr(view, 'name', 'unknown'), type(view).__name__, + getattr(view, 'view_id', 'unknown'), settings) + return + settings["view_type"] = view_type + settings["view_id"] = getattr( + view, + "view_id", + f"view_{view_type}_{len(self.flight_track_settings[json_key]['views'])}" + ) + settings_compare = {k: v for k, v in settings.items() if k != "view_id"} + # Update or append view settings + for i, existing_setting in enumerate(self.flight_track_settings[json_key]["views"]): + existing_compare = {k: v for k, v in existing_setting.items() if k != "view_id"} + if settings_compare == existing_compare: + break + else: + self.flight_track_settings[json_key]["views"].append(settings) + + global_data = view_restoration.set_global_data(flight_track) + self.flight_track_settings[json_key]["global"] = global_data + + except KeyError as ae: + logging.info( + "Failed to update view %s (type: %s, id: %s): %s", + getattr(view, "name", "unknown"), + type(view).__name__, + getattr(view, "view_id", "unknown"), + ae, + ) + return + + def get_operation_view_settings(self): + """Collect settings for all views associated with the active operation.""" + settings = { + "global": { + "op_id": self.mscolab.active_op_id, + "user_id": self.mscolab.user.get("id") if self.mscolab.user else None, + "operation_name": self.mscolab.active_operation_name + }, + "views": [] + } + + if not self.mscolab.active_op_id: + logging.warning("No active operation selected, returning empty settings") + return settings + + for i in range(self.listViews.count()): + view = self.listViews.item(i).window + try: + if hasattr(view, 'get_settings'): + view_settings = view.get_settings() + if not isinstance(view_settings, dict): + logging.warning("Invalid settings from view %s: %s", + getattr(view, 'name', 'unknown'), view_settings) + continue + view_type = getattr(view, 'view_type', type(view).__name__).lower().replace(" ", "") + view_id = getattr(view, 'view_id', f"view_{view_type}_{self.mscolab.active_op_id}_{i}") + view_settings["view_type"] = view_type + view_settings["view_id"] = view_id + settings["views"].append(view_settings) + else: + logging.warning("View %s has no get_settings method", getattr(view, 'name', 'unknown')) + except Exception as ex: + logging.error("Failed to collect settings for view %s: %s", getattr(view, 'name', 'unknown'), ex) + return settings + + def create_operation_view_settings(self, settings): + """Apply retrieved view settings to open views or create new views.""" + if not settings or not isinstance(settings, dict): + logging.warning("No valid settings to apply: %s", settings) + return + + global_data = settings.get("global", {}) + operation_name = global_data.get("operation_name") + + # Verify that settings match the active operation + if operation_name and operation_name != self.mscolab.active_operation_name: + logging.warning( + "Settings operation name (%s) does not match active operation (%s). Skipping settings application.", + operation_name, self.mscolab.active_operation_name + ) + return + views = settings.get("views", []) + # Close existing views to avoid conflicts + while self.listViews.count() > 0: + self.listViews.item(0).window.handle_force_close() + self.listViews.clear() + QActiveViewsListWidgetItem.opened_views = 0 + + for view_setting in views: + view_type = view_setting.get("view_type") + if not view_type: + logging.warning("Skipping view with missing view_type: %s", view_setting) + continue + self.create_view(view_type, self.mscolab.waypoints_model, restore_settings=[view_setting]) + + def save_view_settings(self): + # Save view settings for active flight track + if self.local_active and not self.mscolab.active_op_id and self.active_flight_track: + for i in range(self.listViews.count()): + view = self.listViews.item(i).window + if hasattr(view, 'active_flighttrack') and view.active_flighttrack == self.active_flight_track: + self.update_flight_track_settings(self.active_flight_track, view=view) + for json_key in self.activated_flight_tracks: + settings = self.flight_track_settings.get(json_key) + if settings: + view_restoration.save_view_settings(settings["views"], settings["global"], json_key) + else: + logging.debug("No setting available for %s", json_key) + self.activated_flight_tracks.clear() + def closeEvent(self, event): """Ask user if he/she wants to close the application. If yes, also close all views that are open. @@ -1195,11 +1598,17 @@ def closeEvent(self, event): QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) if ret == QtWidgets.QMessageBox.Yes: + if self.local_active: + self.save_view_settings() + if self.mscolab.active_op_id: + self.mscolab.send_view_settings_to_server() if self.mscolab.help_dialog is not None: self.mscolab.help_dialog.close() # cleanup mscolab widgets + # Save MSColab view settings if self.mscolab.token is not None: self.mscolab.logout() + # Table View stick around after MainWindow closes - maybe some dangling reference? # This removes them for sure! while self.listViews.count() > 0: diff --git a/mslib/msui/qt5/ui_mainwindow.py b/mslib/msui/qt5/ui_mainwindow.py index 77d3dc27f..5a1750daf 100644 --- a/mslib/msui/qt5/ui_mainwindow.py +++ b/mslib/msui/qt5/ui_mainwindow.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file 'mslib/msui/ui/ui_mainwindow.ui' +# Form implementation generated from reading ui file 'ui_mainwindow.ui' # -# Created by: PyQt5 UI code generator 5.15.7 +# Created by: PyQt5 UI code generator 5.15.9 # # WARNING: Any manual changes made to this file will be lost when pyuic5 is # run again. Do not edit this file unless you know what you are doing. @@ -32,6 +32,7 @@ def setupUi(self, MSUIMainWindow): sizePolicy.setHeightForWidth(self.MSColabConnectGb.sizePolicy().hasHeightForWidth()) self.MSColabConnectGb.setSizePolicy(sizePolicy) self.MSColabConnectGb.setMinimumSize(QtCore.QSize(0, 0)) + self.MSColabConnectGb.setMaximumSize(QtCore.QSize(16777215, 100)) self.MSColabConnectGb.setTitle("") self.MSColabConnectGb.setObjectName("MSColabConnectGb") self.verticalLayout_3 = QtWidgets.QVBoxLayout(self.MSColabConnectGb) @@ -49,9 +50,6 @@ def setupUi(self, MSUIMainWindow): self.usernameLabel = QtWidgets.QLabel(self.MSColabConnectGb) self.usernameLabel.setObjectName("usernameLabel") self.userOptionsHL.addWidget(self.usernameLabel, 0, QtCore.Qt.AlignRight) - self.fullnameLabel = QtWidgets.QLabel(self.MSColabConnectGb) - self.fullnameLabel.setObjectName("fullnameLabel") - self.userOptionsHL.addWidget(self.fullnameLabel, 0, QtCore.Qt.AlignRight) self.userOptionsTb = QtWidgets.QToolButton(self.MSColabConnectGb) self.userOptionsTb.setStyleSheet("::menu-indicator { image: none; }") self.userOptionsTb.setText("") @@ -74,6 +72,7 @@ def setupUi(self, MSUIMainWindow): sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.openFlightTracksGb.sizePolicy().hasHeightForWidth()) self.openFlightTracksGb.setSizePolicy(sizePolicy) + self.openFlightTracksGb.setMaximumSize(QtCore.QSize(16777215, 300)) self.openFlightTracksGb.setTitle("") self.openFlightTracksGb.setObjectName("openFlightTracksGb") self.verticalLayout = QtWidgets.QVBoxLayout(self.openFlightTracksGb) @@ -93,6 +92,7 @@ def setupUi(self, MSUIMainWindow): sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth(self.openViewsGb.sizePolicy().hasHeightForWidth()) self.openViewsGb.setSizePolicy(sizePolicy) + self.openViewsGb.setMaximumSize(QtCore.QSize(16777215, 210)) self.openViewsGb.setObjectName("openViewsGb") self.openViewsVL = QtWidgets.QVBoxLayout(self.openViewsGb) self.openViewsVL.setContentsMargins(8, 8, 8, 8) @@ -104,6 +104,23 @@ def setupUi(self, MSUIMainWindow): self.listViews.setObjectName("listViews") self.openViewsVL.addWidget(self.listViews) self.verticalLayout_5.addWidget(self.openViewsGb) + self.shareViewGroupBox = QtWidgets.QGroupBox(self.centralwidget) + self.shareViewGroupBox.setMaximumSize(QtCore.QSize(16777215, 150)) + self.shareViewGroupBox.setTitle("") + self.shareViewGroupBox.setObjectName("shareViewGroupBox") + self.verticalLayout_4 = QtWidgets.QVBoxLayout(self.shareViewGroupBox) + self.verticalLayout_4.setObjectName("verticalLayout_4") + self.label_2 = QtWidgets.QLabel(self.shareViewGroupBox) + self.label_2.setMaximumSize(QtCore.QSize(16777215, 18)) + self.label_2.setObjectName("label_2") + self.verticalLayout_4.addWidget(self.label_2) + self.listView = QtWidgets.QListView(self.shareViewGroupBox) + self.listView.setObjectName("listView") + self.verticalLayout_4.addWidget(self.listView) + self.pushButton = QtWidgets.QPushButton(self.shareViewGroupBox) + self.pushButton.setObjectName("pushButton") + self.verticalLayout_4.addWidget(self.pushButton) + self.verticalLayout_5.addWidget(self.shareViewGroupBox) self.horizontalLayout.addLayout(self.verticalLayout_5) self.openOperationsGb = QtWidgets.QGroupBox(self.centralwidget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred) @@ -179,7 +196,7 @@ def setupUi(self, MSUIMainWindow): self.gridLayout.setColumnStretch(0, 1) MSUIMainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MSUIMainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 738, 22)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 738, 23)) self.menubar.setNativeMenuBar(False) self.menubar.setObjectName("menubar") self.menuFile = QtWidgets.QMenu(self.menubar) @@ -270,6 +287,8 @@ def setupUi(self, MSUIMainWindow): self.actionCopyIntoNewLocalFlightTrack.setObjectName("actionCopyIntoNewLocalFlightTrack") self.actionCopyIntoNewMSColabOperation = QtWidgets.QAction(MSUIMainWindow) self.actionCopyIntoNewMSColabOperation.setObjectName("actionCopyIntoNewMSColabOperation") + self.actionOpenManageView = QtWidgets.QAction(MSUIMainWindow) + self.actionOpenManageView.setObjectName("actionOpenManageView") self.menuImportFlightTrack.addAction(self.actionImportFromSelected) self.menuNew.addAction(self.actionNewFlightTrack) self.menuNew.addAction(self.actionAddOperation) @@ -311,6 +330,10 @@ def setupUi(self, MSUIMainWindow): self.menuOperation.addSeparator() self.menuOperation.addAction(self.actionLeaveOperation) self.menuOperation.addAction(self.menuProperties.menuAction()) + self.menuOperation.addSeparator() + self.menuOperation.addSeparator() + self.menuOperation.addSeparator() + self.menuOperation.addAction(self.actionOpenManageView) self.menubar.addAction(self.menuFile.menuAction()) self.menubar.addAction(self.menuViews.menuAction()) self.menubar.addAction(self.menuOperation.menuAction()) @@ -343,6 +366,8 @@ def retranslateUi(self, MSUIMainWindow): self.listFlightTracks.setSortingEnabled(False) self.openViewsLabel.setText(_translate("MSUIMainWindow", "Open Views:")) self.listViews.setToolTip(_translate("MSUIMainWindow", "Double-click a view to bring it to the front.")) + self.label_2.setText(_translate("MSUIMainWindow", "Share View:")) + self.pushButton.setText(_translate("MSUIMainWindow", "Share")) self.pbOpenOperationArchive.setText(_translate("MSUIMainWindow", "Operation Archive")) self.workingStatusLabel.setText(_translate("MSUIMainWindow", "No operations selected")) self.categoryLabel.setText(_translate("MSUIMainWindow", "Category:")) @@ -414,3 +439,4 @@ def retranslateUi(self, MSUIMainWindow): self.actionCopyIntoMSColabOperation.setText(_translate("MSUIMainWindow", "MSColab Operation")) self.actionCopyIntoNewLocalFlightTrack.setText(_translate("MSUIMainWindow", "Local Flight Track")) self.actionCopyIntoNewMSColabOperation.setText(_translate("MSUIMainWindow", "MSColab Operation")) + self.actionOpenManageView.setText(_translate("MSUIMainWindow", "&Manage Views")) diff --git a/mslib/msui/qt5/ui_manageView_dialog.py b/mslib/msui/qt5/ui_manageView_dialog.py new file mode 100644 index 000000000..41ca42836 --- /dev/null +++ b/mslib/msui/qt5/ui_manageView_dialog.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'ui_manageView_dialog.ui' +# +# Created by: PyQt5 UI code generator 5.15.9 +# +# WARNING: Any manual changes made to this file will be lost when pyuic5 is +# run again. Do not edit this file unless you know what you are doing. + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_Form(object): + def setupUi(self, Form): + Form.setObjectName("Form") + Form.resize(519, 523) + self.verticalLayout_2 = QtWidgets.QVBoxLayout(Form) + self.verticalLayout_2.setObjectName("verticalLayout_2") + self.groupBox = QtWidgets.QGroupBox(Form) + self.groupBox.setTitle("") + self.groupBox.setObjectName("groupBox") + self.verticalLayout = QtWidgets.QVBoxLayout(self.groupBox) + self.verticalLayout.setObjectName("verticalLayout") + self.label_2 = QtWidgets.QLabel(self.groupBox) + font = QtGui.QFont() + font.setPointSize(10) + self.label_2.setFont(font) + self.label_2.setObjectName("label_2") + self.verticalLayout.addWidget(self.label_2) + self.listWidget = QtWidgets.QListWidget(self.groupBox) + self.listWidget.setObjectName("listWidget") + self.verticalLayout.addWidget(self.listWidget) + self.label_3 = QtWidgets.QLabel(self.groupBox) + font = QtGui.QFont() + font.setPointSize(10) + self.label_3.setFont(font) + self.label_3.setObjectName("label_3") + self.verticalLayout.addWidget(self.label_3) + self.listView = QtWidgets.QListView(self.groupBox) + self.listView.setObjectName("listView") + self.verticalLayout.addWidget(self.listView) + self.pushButton = QtWidgets.QPushButton(self.groupBox) + self.pushButton.setObjectName("pushButton") + self.verticalLayout.addWidget(self.pushButton) + self.verticalLayout_2.addWidget(self.groupBox) + + self.retranslateUi(Form) + QtCore.QMetaObject.connectSlotsByName(Form) + + def retranslateUi(self, Form): + _translate = QtCore.QCoreApplication.translate + Form.setWindowTitle(_translate("Form", "Form")) + self.label_2.setText(_translate("Form", "Your Saved View Collections:")) + self.label_3.setText(_translate("Form", "Shared View Collections ( All Users ):")) + self.pushButton.setText(_translate("Form", "Apply Selected View")) diff --git a/mslib/msui/sideview.py b/mslib/msui/sideview.py index 3ee591872..385b8ac28 100644 --- a/mslib/msui/sideview.py +++ b/mslib/msui/sideview.py @@ -28,6 +28,7 @@ import logging import functools +import traceback from PyQt5 import QtGui, QtWidgets, QtCore from mslib.msui.qt5 import ui_sideview_window as ui from mslib.msui.qt5 import ui_sideview_options as ui_opt @@ -423,6 +424,7 @@ def setFlightTrackModel(self, model): Set the QAbstractItemModel instance that the view displays. """ super().setFlightTrackModel(model) + self.active_flighttrack = model if self.docks[WMS] is not None: self.docks[WMS].widget().setFlightTrackModel(model) @@ -460,3 +462,200 @@ def set_line_style(self, style): def set_line_transparency(self, transparency): """Set the line transparency of the flight track""" self.mpl.canvas.waypoints_interactor.set_line_transparency(transparency) + + def get_settings(self): + """Return a dictionary of all side view settings.""" + + # Get settings from the view (matplotlib canvas) + view_settings = self.getView().get_settings() + + # Get WMS settings (if connected) + wms_settings = {} + if self.docks[0] is not None: + wms_settings = { + "url": self.currurl, + "layer": self.currlayer, + "level": self.currlevel, + "styles": self.currstyles, + "init_time": self.curritime, + "valid_time": self.currvtime, + } + + # Get dock widget states + dock_states = [dock is not None for dock in self.docks] + + return { + "view_type": "sideview", + "vertical_axis": view_settings.get("vertical_axis"), + "vertical_extent": view_settings.get("vertical_extent"), + "secondary_axis": view_settings.get("secondary_axis"), + "plot_title_size": view_settings.get("plot_title_size"), + "axes_label_size": view_settings.get("axes_label_size"), + "flightlevels": view_settings.get("flightlevels"), + "draw_ceiling": view_settings.get("draw_ceiling"), + "draw_verticals": view_settings.get("draw_verticals"), + "draw_marker": view_settings.get("draw_marker"), + "draw_flightlevels": view_settings.get("draw_flightlevels"), + "draw_flighttrack": view_settings.get("draw_flighttrack"), + "fill_flighttrack": view_settings.get("fill_flighttrack"), + "label_flighttrack": view_settings.get("label_flighttrack"), + "line_thickness": view_settings.get("line_thickness"), + "line_style": view_settings.get("line_style"), + "line_transparency": view_settings.get("line_transparency"), + "colour_ft_vertices": view_settings.get("colour_ft_vertices"), + "colour_ft_waypoints": view_settings.get("colour_ft_waypoints"), + "colour_ft_fill": view_settings.get("colour_ft_fill"), + "colour_ceiling": view_settings.get("colour_ceiling"), + "wms": wms_settings, + "docks_open": dock_states, + } + + def set_settings(self, view): + try: + if isinstance(view, list): + view = next((v for v in view if v.get("view_type") == "sideview"), {}) + + if not hasattr(self, 'docks') or not self.docks: + self.docks = [None, None] + + plot_settings = { + "vertical_axis": view.get("vertical_axis", "pressure"), + "vertical_extent": view.get("vertical_extent", [1000.0, 100.0]), + "secondary_axis": view.get("secondary_axis", "no secondary axis"), + "plot_title_size": view.get("plot_title_size", "10pt"), + "axes_label_size": view.get("axes_label_size", "10pt"), + "flightlevels": view.get("flightlevels", [0]), + "draw_ceiling": view.get("draw_ceiling", True), + "draw_verticals": view.get("draw_verticals", True), + "draw_marker": view.get("draw_marker", True), + "draw_flightlevels": view.get("draw_flightlevels", True), + "draw_flighttrack": view.get("draw_flighttrack", True), + "fill_flighttrack": view.get("fill_flighttrack", True), + "label_flighttrack": view.get("label_flighttrack", True), + "line_thickness": view.get("line_thickness", 2.0), + "line_style": view.get("line_style", "Solid"), + "line_transparency": view.get("line_transparency", 1.0), + "colour_ft_vertices": view.get("colour_ft_vertices", [0, 0, 0, 1]), + "colour_ft_waypoints": view.get("colour_ft_waypoints", [0, 0, 0, 1]), + "colour_ft_fill": view.get("colour_ft_fill", [0.5, 0.5, 0.5, 0.5]), + "colour_ceiling": view.get("colour_ceiling", [0, 0, 1, 0.5]) + } + + waypoints_model = getattr(self, 'waypoints_model', None) + if waypoints_model is None and hasattr(self, 'mainwindow') and self.mainwindow.active_flight_track: + waypoints_model = self.mainwindow.active_flight_track + logging.warning("waypoints_model not initialized; using mainwindow.active_flight_track") + if waypoints_model: + try: + self.setFlightTrackModel(waypoints_model) + except Exception as e: + logging.error("Error updating plotter from shared waypoints: %s\n%s", + str(e), traceback.format_exc()) + else: + logging.error("No waypoints_model available; cannot update waypoints") + + if hasattr(self, 'mpl') and self.mpl.canvas: + try: + self.mpl.canvas.plotter.set_settings(plot_settings, save=True) + except Exception as e: + logging.warning("Failed to restore plot settings: %s", str(e)) + + wms_settings = view.get("wms", {}) + if wms_settings: + if len(self.docks) < 1 or self.docks[WMS] is None: + self.openTool(WMS + 1) + if self.docks[WMS]: + self.wms_control = self.docks[WMS].widget() + if self.wms_control and isinstance(self.wms_control, wms.VSecWMSControlWidget): + self.restore_wms_settings(wms_settings) + else: + logging.warning("WMS control widget not available; got %s", type(self.wms_control)) + else: + logging.warning("WMS dock not initialized") + else: + logging.debug("No WMS settings provided; skipping WMS restoration") + + docks_open = view.get("docks_open", [False] * len(getattr(self, 'docks', []))) + if hasattr(self, 'docks'): + for idx, state in enumerate(docks_open): + if idx < len(self.docks): + if state and self.docks[idx] is None: + self.openTool(idx + 1) + elif self.docks[idx]: + self.docks[idx].setVisible(state) + + if hasattr(self, 'mpl') and self.mpl.canvas: + self.mpl.canvas.draw() + except Exception as e: + logging.error("Error in set_settings: %s\n%s", str(e), traceback.format_exc()) + + def restore_wms_settings(self, wms): + """ + Restore WMS settings into the existing WMS control widget. + """ + if not self.wms_control: + logging.warning("Cannot restore WMS settings: wms_control does not exist") + return + + try: + url = wms.get("url", "") + layer = wms.get("layer", "") + level = wms.get("level", "") + styles = wms.get("styles", "") + init_time = wms.get("init_time", "") + valid_time = wms.get("valid_time", "") + + if not url: + logging.warning("No WMS URL provided") + return + + self.wms_control.initialise_wms(url, level=level or "") + self.wms_url, self.wms_layer, self.wms_level, self.wms_styles = url, layer, level, styles + self.current_init_time = ( + QtCore.QDateTime.fromString(init_time, QtCore.Qt.ISODate) + if init_time else QtCore.QDateTime.currentDateTimeUtc() + ) + self.current_valid_time = ( + QtCore.QDateTime.fromString(valid_time, QtCore.Qt.ISODate) + if valid_time else QtCore.QDateTime.currentDateTimeUtc() + ) + + cb_url = getattr(self.wms_control.multilayers, 'cbWMS_URL', None) + if cb_url: + cb_url.setCurrentText(url) + cb_url.currentTextChanged.emit(url) + QtCore.QCoreApplication.processEvents() + else: + logging.error("WMS URL combobox 'cbWMS_URL' not found") + return + + self.wms_control.select_layer_and_style( + self.wms_control.multilayers.listLayers, layer, styles + ) + self.wms_control.row_is_selected(url, layer, styles, level, "side") + + if init_time: + idx = self.wms_control.cbInitTime.findText(init_time) + if idx >= 0: + self.wms_control.cbInitTime.setCurrentIndex(idx) + else: + logging.warning("Init time %s not found in combo box", init_time) + + if valid_time: + idx = self.wms_control.cbValidTime.findText(valid_time) + if idx >= 0: + self.wms_control.cbValidTime.setCurrentIndex(idx) + self.wms_control.leftrow_is_selected(valid_time) + else: + logging.warning("Valid time %s not found in combo box", valid_time) + + target_layer_item = self.wms_control.find_layer_item_by_name(layer) + if target_layer_item: + self.wms_control.multilayers.current_layer = target_layer_item + self.wms_control.call_get_vsec() + self.wms_connected = True + if hasattr(self, 'mpl') and self.mpl.canvas: + self.mpl.canvas.redraw_map() + + except Exception as e: + logging.error("Error restoring WMS settings: %s\n%s", str(e), traceback.format_exc()) diff --git a/mslib/msui/tableview.py b/mslib/msui/tableview.py index e49542b0b..c721a5af5 100644 --- a/mslib/msui/tableview.py +++ b/mslib/msui/tableview.py @@ -33,7 +33,9 @@ """ import types - +import traceback +import logging +from mslib.msui import aircraft from mslib.msui import hexagon_dockwidget as hex_dock from mslib.msui import performance_settings as perfset from PyQt5 import QtWidgets, QtGui @@ -42,6 +44,9 @@ from mslib.msui import flighttrack as ft from mslib.msui.viewwindows import MSUIViewWindow from mslib.msui.icons import icons +from PyQt5 import QtCore +from mslib.utils import view_restoration +from mslib.utils.config import config_loader try: import mpl_toolkits.basemap.pyproj as pyproj @@ -78,6 +83,12 @@ def __init__(self, parent=None, model=None, _id=None, tutorial_mode=False): # Dock windows [Hexagon]. self.docks = [None, None] + self.hexagon_center_lon = 0.0 + self.hexagon_center_lat = 0.0 + self.hexagon_radius = 200.0 + self.hexagon_angle = 0.0 + self.hexagon_direction = "clockwise" + # Connect slots and signals. self.btAddWayPointToFlightTrack.clicked.connect(self.addWayPoint) self.btCloneWaypoint.clicked.connect(self.cloneWaypoint) @@ -274,6 +285,7 @@ def setFlightTrackModel(self, model): Set the QAbstractItemModel instance that the table displays. """ super().setFlightTrackModel(model) + self.active_flighttrack = model self.tableWayPoints.setModel(self.waypoints_model) # Automatically enable or disable roundtrip when data changes @@ -291,3 +303,275 @@ def viewPerformance(self): self.btAddWayPointToFlightTrack.setEnabled(True) self.btDeleteWayPoint.setEnabled(True) self.resizeColumns() + + def get_settings(self): + """Return a dictionary of all Table View settings.""" + try: + performance_settings = {} + dock_states = [False, False] + column_widths = {} + hexagon_settings = { + "center_lon": getattr(self, 'hexagon_center_lon', 0.0), + "center_lat": getattr(self, 'hexagon_center_lat', 0.0), + "radius": getattr(self, 'hexagon_radius', 200.0), + "angle": getattr(self, 'hexagon_angle', 0.0), + "direction": getattr(self, 'hexagon_direction', "clockwise") + } + + # Performance settings + if hasattr(self, 'waypoints_model') and self.waypoints_model: + raw_perf = getattr(self.waypoints_model, 'performance_settings', {}) + try: + performance_settings = view_restoration.serialize_settings(raw_perf) + for key, value in raw_perf.items(): + if isinstance(value, QtCore.QDateTime): + performance_settings[key] = value.toString(QtCore.Qt.ISODate) + except Exception as ex: + logging.error("Failed to serialize performance_settings: %s", ex) + else: + logging.warning("performance_settings is not a dict: %s", type(raw_perf)) + + # Dock states + if hasattr(self, 'docks') and isinstance(self.docks, list): + dock_states = [dock.isVisible() if dock else False for dock in self.docks] + + # Column widths + sort_column = None + sort_order = None + if hasattr(self, 'tableWayPoints') and self.tableWayPoints: + model = self.tableWayPoints.model() + if model: + for col in range(model.columnCount()): + header_data = model.headerData(col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole) + header = str(header_data) if header_data else f"Column_{col}" + column_widths[header] = self.tableWayPoints.columnWidth(col) + + # Sorting info (safe access) + try: + sort_col = self.tableWayPoints.horizontalHeader().sortIndicatorSection() + sort_order = self.tableWayPoints.horizontalHeader().sortIndicatorOrder() + if sort_col >= 0: + sort_column = model.headerData(sort_col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole) + except Exception as ex: + logging.warning("Could not get sort column info: %s", ex) + + # Hexagon settings from widget + try: + if self.docks and len(self.docks) > 0 and self.docks[0] and self.docks[0].widget(): + hex_control = self.docks[0].widget() + if isinstance(hex_control, hex_dock.HexagonControlWidget): + hexagon_settings = hex_control._get_parameters() + except Exception as ex: + logging.warning("Failed to collect hexagon settings: %s", ex) + + # Final settings dictionary + settings = { + "view_type": "tableview", + "performance_settings": performance_settings, + "docks_open": dock_states, + "column_widths": column_widths, + "hexagon": hexagon_settings, + } + if sort_column: + settings["sort_column"] = str(sort_column) + settings["sort_order"] = "ascending" if sort_order == QtCore.Qt.AscendingOrder else "descending" + + logging.debug("Collected Table View settings: %s", settings) + return settings + + except Exception as ex: + logging.error("Failed to get TableView settings: %s", ex) + return { + "view_type": "tableview", + "performance_settings": {}, + "docks_open": [False, False], + "column_widths": config_loader(dataset="default_table_column_widths", default={}), + "hexagon": { + "center_lon": 0.0, + "center_lat": 0.0, + "radius": 200.0, + "angle": 0.0, + "direction": "clockwise" + } + } + + def set_settings(self, view): + """Restore Table View settings from view_settings.json.""" + try: + if isinstance(view, list): + view = next((v for v in view if v.get("view_type") == "tableview"), {}) + + self.docks = getattr(self, 'docks', [None, None]) + + # Restore waypoints_model data + if hasattr(self, 'waypoints_model') and self.waypoints_model: + try: + for row in range(self.waypoints_model.rowCount()): + index = self.waypoints_model.index( + row, self.waypoints_model.column_index("flightlevel") + ) + value = self.waypoints_model.data(index, QtCore.Qt.DisplayRole) + if isinstance(value, QtCore.QVariant): + value = value.value() + try: + flightlevel = float(value) + except (TypeError, ValueError): + logging.warning("Invalid flightlevel at row %d: %s", row, value) + flightlevel = 300.0 + if flightlevel < 300: + self.waypoints_model.setData(index, 300.0, QtCore.Qt.EditRole) + self.tableWayPoints.setModel(self.waypoints_model) + self.resizeColumns() + except Exception as e: + logging.error( + "Error updating waypoints in Table View: %s\n%s", str(e), traceback.format_exc() + ) + else: + logging.warning("waypoints_model not initialized; skipping waypoint update") + + # Restore hexagon settings + hexagon_settings = view.get("hexagon", {}) + if hexagon_settings: + if self.docks[0] is None: + self.openTool(1) + if self.docks[0]: + self.hexagon_control = self.docks[0].widget() + if isinstance(self.hexagon_control, hex_dock.HexagonControlWidget): + self.restore_hexagon_settings(hexagon_settings) + else: + logging.warning( + "Hexagon control widget not available; got %s", type(self.hexagon_control) + ) + else: + logging.warning("Hexagon control dock not initialized") + + # Restore performance settings + perf = view.get("performance_settings", {}) + if self.docks[1] is None: + self.openTool(2) + if self.docks[1]: + perf_ctrl = self.docks[1].widget() + if isinstance(perf_ctrl, perfset.MSUI_PerformanceSettingsWidget): + try: + aircraft_data = perf.get( + "aircraft", {"name": "DUMMY", "empty_weight": 0.0, "takeoff_weight": 0.0} + ) + perf_ctrl.aircraft = aircraft.SimpleAircraft(aircraft_data) + perf_ctrl.lbAircraftName.setText(perf_ctrl.aircraft.name) + perf_ctrl.cbShowPerformance.setChecked(perf.get("visible", False)) + takeoff_weight = perf.get( + "takeoff_weight", aircraft_data.get("takeoff_weight", 0.0) + ) + empty_weight = perf.get( + "empty_weight", aircraft_data.get("empty_weight", 0.0) + ) + perf_ctrl.dsbTakeoffWeight.setValue(float(takeoff_weight)) + perf_ctrl.dsbEmptyWeight.setValue(float(empty_weight)) + takeoff_time = perf.get( + "takeoff_time", + QtCore.QDateTime.currentDateTimeUtc().toString(QtCore.Qt.ISODate) + ) + if isinstance(takeoff_time, str): + takeoff_time = QtCore.QDateTime.fromString(takeoff_time, QtCore.Qt.ISODate) + perf_ctrl.dteTakeoffTime.setDateTime( + takeoff_time or QtCore.QDateTime.currentDateTimeUtc() + ) + perf_ctrl.update_parent_performance() + except Exception as e: + logging.warning("Failed to restore performance settings: %s", str(e)) + else: + logging.warning("Performance settings widget not available; got %s", type(perf_ctrl)) + else: + logging.warning("Performance settings dock not initialized") + + # Restore column widths + column_widths = view.get("column_widths", {}) + if self.tableWayPoints and column_widths: + model = self.tableWayPoints.model() + if model: + headers = {} + for col in range(model.columnCount()): + header = model.headerData(col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole) + header_str = str(header) if header is not None else f"Column_{col}" + headers[header_str] = col + for col_name, width in column_widths.items(): + col_idx = headers.get(str(col_name)) + if col_idx is not None: + try: + self.tableWayPoints.setColumnWidth(col_idx, int(width)) + except Exception as e: + logging.warning( + "Failed to set column width for %s: %s", col_name, str(e) + ) + else: + logging.warning( + "Column '%s' not found in table headers: %s", + col_name, list(headers.keys()) + ) + + # Restore dock visibility + docks_open = view.get("docks_open", [False, False]) + for idx, open_ in enumerate(docks_open): + if idx < len(self.docks): + if open_ and self.docks[idx] is None: + self.openTool(idx + 1) + elif self.docks[idx]: + self.docks[idx].setVisible(open_) + + # Repaint table + if self.tableWayPoints: + self.tableWayPoints.viewport().repaint() + + except Exception as e: + logging.error("Error in set_settings: %s\n%s", str(e), traceback.format_exc()) + + def restore_hexagon_settings(self, hexagon_settings): + """Restore hexagon settings into the existing HexagonControlWidget.""" + try: + center_lon = float(hexagon_settings.get("center_lon", 0.0)) + center_lat = float(hexagon_settings.get("center_lat", 0.0)) + radius = float(hexagon_settings.get("radius", 200.0)) + angle = float(hexagon_settings.get("angle", 0.0)) + direction = str(hexagon_settings.get("direction", "clockwise")) + + self.hexagon_center_lon = center_lon + self.hexagon_center_lat = center_lat + self.hexagon_radius = radius + self.hexagon_angle = angle + self.hexagon_direction = direction + + if self.hexagon_control and isinstance(self.hexagon_control, hex_dock.HexagonControlWidget): + if hasattr(self.hexagon_control, 'dsbHexagonLongitude'): + self.hexagon_control.dsbHexagonLongitude.setValue(center_lon) + else: + logging.warning("Hexagon longitude spinbox 'dsbHexagonLongitude' not found") + + if hasattr(self.hexagon_control, 'dsbHexagonLatitude'): + self.hexagon_control.dsbHexagonLatitude.setValue(center_lat) + else: + logging.warning("Hexagon latitude spinbox 'dsbHexagonLatitude' not found") + + if hasattr(self.hexagon_control, 'dsbHexgaonRadius'): + self.hexagon_control.dsbHexgaonRadius.setValue(radius) + else: + logging.warning("Hexagon radius spinbox 'dsbHexgaonRadius' not found") + + if hasattr(self.hexagon_control, 'dsbHexagonAngle'): + self.hexagon_control.dsbHexagonAngle.setValue(angle) + else: + logging.warning("Hexagon angle spinbox 'dsbHexagonAngle' not found") + + if hasattr(self.hexagon_control, 'cbClock'): + dir_text = direction if direction in ["clockwise", "counterclockwise"] else "clockwise" + self.hexagon_control.cbClock.setCurrentText(dir_text) + else: + logging.warning("Hexagon direction combobox 'cbClock' not found") + + QtCore.QCoreApplication.processEvents() + else: + logging.warning( + "Hexagon control widget not available or incorrect type: %s", + type(self.hexagon_control) if self.hexagon_control else "None" + ) + except Exception as e: + logging.error("Error restoring hexagon settings: %s\n%s", str(e), traceback.format_exc()) diff --git a/mslib/msui/topview.py b/mslib/msui/topview.py index f892da606..62fcbc2b8 100644 --- a/mslib/msui/topview.py +++ b/mslib/msui/topview.py @@ -48,6 +48,7 @@ from mslib.msui.icons import icons from mslib.msui.flighttrack import Waypoint from mslib.utils.colordialog import CustomColorDialog +import traceback # Dock window indices. WMS = 0 @@ -365,6 +366,9 @@ def setup_top_view(self): self.mpl.navbar.push_current() self.openTool(WMS + 1) + if self.docks[WMS]: + self.wms_control = self.docks[WMS].widget() + self.docks[WMS].setVisible(True) def update_predefined_maps(self, extra=None): current_map_key = self.cbChangeMapSection.currentText() @@ -482,8 +486,15 @@ def valid_time_vals(self, vtimes_list): def layer_val_changed(self, strr): self.currlayerobj = strr layerstring = str(strr) - second_colon_index = layerstring.find(':', layerstring.find(':') + 1) - self.currurl = layerstring[:second_colon_index].strip() if second_colon_index != -1 else layerstring.strip() + first_colon_index = layerstring.find(':') + second_colon_index = layerstring.find(':', first_colon_index + 1) + third_colon_index = layerstring.find(':', second_colon_index + 1) + if third_colon_index != -1: + self.currurl = layerstring[:third_colon_index].strip() + elif second_colon_index != -1: + self.currurl = layerstring[:second_colon_index].strip() + else: + layerstring.strip() self.currlayer = layerstring.split('|')[1].strip() if '|' in layerstring else None @QtCore.pyqtSlot() @@ -608,3 +619,185 @@ def is_roundtrip_possible(self): def update_roundtrip_enabled(self): self.btRoundtrip.setEnabled(self.is_roundtrip_possible()) + + def get_settings(self): + """Return a dictionary of all top view settings.""" + try: + # Get current map section and projection + current_map_key = self.cbChangeMapSection.currentText() + predefined_map_sections = config_loader(dataset="predefined_map_sections") + current_map = predefined_map_sections.get(current_map_key, {"CRS": "EPSG:4326", "map": {}}) + projection = current_map.get("CRS", "EPSG:4326") + + # Validate projection + supported_projections = ['cyl', 'merc', 'mill', 'lcc', 'laea', 'EPSG:4326', 'EPSG:3857'] + if projection not in supported_projections: + logging.warning(f"Unsupported projection '{projection}', falling back to 'EPSG:4326'") + projection = "EPSG:4326" + + # Get flight track appearance settings and waypoints + appearance_settings = self.mpl.canvas.get_settings() + if not isinstance(appearance_settings, dict): + logging.warning("Invalid appearance settings from mpl.canvas, expected dict, got %s: %s", + type(appearance_settings).__name__, appearance_settings) + appearance_settings = {} + + # Get WMS settings (if connected) + wms_settings = {} + if self.wms_connected: + wms_settings = { + "url": self.currurl, + "layer": self.currlayer, + "level": self.currlevel, + "styles": self.currstyles, + "init_time": self.curritime, + "valid_time": self.currvtime, + } + if wms_settings["url"] is None: + return "" + + # Get dock widget states + dock_states = [dock is not None for dock in self.docks] + + # Get current extent from Basemap (may differ from predefined) + lon_min = self.mpl.canvas.map.llcrnrlon + lon_max = self.mpl.canvas.map.urcrnrlon + lat_min = self.mpl.canvas.map.llcrnrlat + lat_max = self.mpl.canvas.map.urcrnrlat + + # Validate: if lat_min == lat_max or lon_min == lon_max, use defaults + if lat_min == lat_max or lon_min == lon_max: + logging.warning("Invalid extent read from map, using defaults") + lon_min, lon_max = -120.0, 120.0 + lat_min, lat_max = -60.0, 60.0 + + return { + "view_type": "topview", + "map_section": current_map_key, + "projection": projection, + "extent": { + "lon_min": lon_min, + "lon_max": lon_max, + "lat_min": lat_min, + "lat_max": lat_max + }, + "flight_track": appearance_settings, + "wms": wms_settings, + "docks_open": dock_states, + } + except AttributeError as ae: + logging.info("Map extent not available: %s", str(ae)) + return {} + + def restore_wms_settings(self, wms): + """ + Restore WMS settings into the existing WMS control widget (Side View). + """ + if self.wms_control is None: + logging.warning("Cannot restore WMS settings: wms_control does not exist") + return + + try: + self.wms_control.reset_wms() + self.wms_connected = False + + url, layer, level = wms.get("url"), wms.get("layer"), wms.get("level") + styles, init_time, valid_time = wms.get("styles"), wms.get("init_time"), wms.get("valid_time") + + # Optionally initialise + self.wms_control.initialise_wms(url, level=level or "") + + self.wms_control.multilayers.cbWMS_URL.setCurrentText(url) + self.wms_control.select_layer_and_style(self.wms_control.multilayers.listLayers, layer, styles) + self.wms_control.row_is_selected(url, layer, styles, level, "top") + + if init_time: + idx = self.wms_control.cbInitTime.findText(init_time) + if idx >= 0: + self.wms_control.cbInitTime.setCurrentIndex(idx) + + idx = self.wms_control.cbValidTime.findText(valid_time) + if idx >= 0: + self.wms_control.cbValidTime.setCurrentIndex(idx) + self.wms_control.leftrow_is_selected(valid_time) + else: + logging.warning("Valid time %s not found in combo box", valid_time) + + target_layer_item = self.wms_control.find_layer_item_by_name(layer) + + if target_layer_item: + self.wms_control.multilayers.current_layer = target_layer_item + self.wms_control.get_map() + + self.wms_connected = True + self.mpl.canvas.redraw_map() + + except Exception as e: + logging.error("Error restoring WMS settings: %s\n%s", str(e), traceback.format_exc()) + + def set_settings(self, view): + """ + Restore non-WMS Top View settings: + - map section, projection, extent + - flight track appearance + - waypoints + - dock visibility + Calls restore_wms_settings if WMS is included. + """ + try: + if isinstance(view, list): + view = next((v for v in view if v.get("view_type") == "topview"), {}) + + # Clear existing WMS state + if self.wms_control: + self.wms_control.reset_wms() + self.wms_connected = False + + map_section = view.get("map_section") + projection = view.get("projection") + extent = view.get("extent") + + predefined = config_loader(dataset="predefined_map_sections") + if map_section in predefined: + map_settings = predefined[map_section].get("map", {}).copy() + map_settings.update({"CRS": projection}) + map_settings.update({ + "llcrnrlon": extent.get("lon_min", -120.0), + "urcrnrlon": extent.get("lon_max", 120.0), + "llcrnrlat": extent.get("lat_min", -60.0), + "urcrnrlat": extent.get("lat_max", 60.0), + }) + + self.cbChangeMapSection.setCurrentText(map_section) + if not getattr(self.mpl.canvas, "map", None): + self.mpl.canvas.init_map(model=self.active_flighttrack, **map_settings) + else: + self.mpl.canvas.redraw_map(kwargs_update=map_settings) + + # Restore flight track appearance + flight_track = view.get("flight_track", {}) + if flight_track: + self.mpl.canvas.set_settings(flight_track) + + # Update plot + self.mpl.canvas.waypoints_interactor.plotter.update_from_waypoints( + self.active_flighttrack.all_waypoint_data()) + + # Restore docks + docks_open = view.get("docks_open", []) + if hasattr(self, 'docks') and self.docks: + for idx, state in enumerate(docks_open): + if idx < len(self.docks) and self.docks[idx]: + self.docks[idx].setVisible(state) + else: + logging.warning("Docks not initialized; skipping dock visibility restore") + + wms = view.get("wms", {}) + if wms: + self.restore_wms_settings(wms) + + self.mpl.canvas.draw() + # Redraw path last to ensure all settings (waypoints, map, WMS, docks) are applied + # self.mpl.canvas.waypoints_interactor.redraw_path() + except Exception as e: + logging.error("Error restoring non-WMS Top View settings: %s\n%s", str(e), traceback.format_exc()) diff --git a/mslib/msui/ui/ui_mainwindow.ui b/mslib/msui/ui/ui_mainwindow.ui index 0c049ab93..2ac3048ca 100644 --- a/mslib/msui/ui/ui_mainwindow.ui +++ b/mslib/msui/ui/ui_mainwindow.ui @@ -55,6 +55,12 @@ 0 + + + 16777215 + 100 + + @@ -134,6 +140,12 @@ 0 + + + 16777215 + 300 + + @@ -183,6 +195,12 @@ Save a flight track to name it. 0 + + + 16777215 + 210 + + 8 @@ -213,6 +231,44 @@ Save a flight track to name it. + + + + + 16777215 + 150 + + + + + + + + + + + 16777215 + 18 + + + + Share View: + + + + + + + + + + Share + + + + + + @@ -420,7 +476,7 @@ Double click a operation to activate and view its description. 0 0 738 - 22 + 23 @@ -511,6 +567,10 @@ Double click a operation to activate and view its description. + + + + @@ -719,6 +779,11 @@ Double click a operation to activate and view its description. MSColab Operation + + + Manage view + + connectBtn diff --git a/mslib/msui/ui/ui_manageView_dialog.ui b/mslib/msui/ui/ui_manageView_dialog.ui new file mode 100644 index 000000000..3d175f572 --- /dev/null +++ b/mslib/msui/ui/ui_manageView_dialog.ui @@ -0,0 +1,67 @@ + + + Form + + + + 0 + 0 + 519 + 523 + + + + Form + + + + + + + + + + + + + 10 + + + + Your Saved View Collections: + + + + + + + + + + + 10 + + + + Shared View Collections ( All Users ): + + + + + + + + + + Apply Selected View + + + + + + + + + + + diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index 6d733605e..dd527e9f2 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -1743,6 +1743,35 @@ def append_multiple_images(self, imgs): result.thumbnail((result.width, max_height), Image.LANCZOS) return result + def find_layer_item_by_name(self, layer_name): + """ + Search multilayers.listLayers tree for a child with matching name. + """ + for i in range(self.multilayers.listLayers.topLevelItemCount()): + top_item = self.multilayers.listLayers.topLevelItem(i) + for j in range(top_item.childCount()): + child = top_item.child(j) + if child.text(0).strip() == layer_name.strip(): + return child + return None + + def reset_wms(self): + """ + Reset WMS control to a clean state. + """ + try: + self.multilayers.cbWMS_URL.setCurrentIndex(-1) + self.multilayers.listLayers.clearSelection() + self.cbInitTime.setCurrentIndex(-1) + self.cbValidTime.setCurrentIndex(-1) + self.multilayers.current_layer = None + # Clear any cached WMS data + if hasattr(self, 'wms_client'): + self.wms_client = None + logging.debug("WMS control reset") + except Exception as e: + logging.error("Error resetting WMS control: %s", str(e)) + class VSecWMSControlWidget(WMSControlWidget): """Subclass of WMSControlWidget that extends the WMS client to diff --git a/mslib/utils/config.py b/mslib/utils/config.py index ade7c1648..5a2722b89 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -56,6 +56,10 @@ class MSUIDefaultConfig: Do not change any value for good reasons. Your values can be set in your personal msui_settings.json file """ + + # New setting for view restorations + restore_views = False + # this skips the verification of the user token on each mscolab request mscolab_skip_verify_user_token = True @@ -314,6 +318,7 @@ class MSUIDefaultConfig: # Fixed key/value pair options key_value_options = [ + 'restore_views', 'mscolab_skip_verify_user_token', 'filepicker_default', 'mss_dir', @@ -409,6 +414,7 @@ class MSUIDefaultConfig: "topview": "Dictionary to make title, label, and ticklabel sizes for topview configurable", "sideview": "Dictionary to make title, label, and ticklabel sizes for sideview configurable", "linearview": "Dictionary to make title, label, and ticklabel sizes for linearview configurable", + "restore_views": "Enable restoration of view settings (e.g. top view) on startup", } diff --git a/mslib/utils/view_restoration.py b/mslib/utils/view_restoration.py new file mode 100644 index 000000000..3a9702b21 --- /dev/null +++ b/mslib/utils/view_restoration.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +""" + mslib/utils/view_restoration.py + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This file is part of MSS. + + :copyright: Copyright 2025 Annapurna Gupta. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import json +import logging +from PyQt5 import QtCore +from pathlib import Path +from PyQt5 import QtGui +from mslib.msui import constants +from mslib import __version__ as mss_version + + +def save_view_settings(settings, global_data, flight_track_name): + """ + Save view settings (for top, side, linear, and table views) to a JSON file. + """ + if not isinstance(settings, list): + raise TypeError("Settings must be a list of dictionaries") + for setting in settings: + if not isinstance(setting, dict) or "view_type" not in setting: + raise ValueError("Each setting must be a dictionary with a 'view_type' key") + if not isinstance(global_data, dict): + logging.warning("Invalid global_data; using empty dictionary") + global_data = {} + if not isinstance(flight_track_name, str): + logging.warning("Invalid flight_track_name '%s'; converting to string", flight_track_name) + flight_track_name = str(flight_track_name) + + try: + config_path = Path(constants.MSUI_CONFIG_PATH) + config_path.mkdir(parents=True, exist_ok=True) + settings_file = config_path / "view_settings.json" + settings_data = {} + if settings_file.exists(): + try: + with settings_file.open("r", encoding="utf-8") as f: + settings_data = json.load(f) + except json.JSONDecodeError: + logging.warning("Corrupted view_settings.json, initializing new file") + + settings_data[flight_track_name] = { + "global": global_data, + "views": settings + } + with settings_file.open("w", encoding="utf-8") as f: + json.dump(settings_data, f, indent=2) + return True + except Exception as e: + logging.error("Failed to save view settings for %s: %s", flight_track_name, str(e)) + return False + + +def set_global_data(flight_track=None): + """ + Create the global section for view settings using waypoints from flight_track or topview. + """ + if flight_track: + flight_track_name = flight_track.name + else: + logging.warning("No flight track provided; using default flight track name") + + return { + "mss_version": str(mss_version), + "flight_track_name": str(flight_track_name) + } + + +def serializer(obj): + """ + Recursively serialize a list of settings dictionaries to make them JSON-serializable. + """ + if hasattr(obj, '__dict__'): + return { + attr: getattr(obj, attr) + for attr in dir(obj) + if not attr.startswith('_') and isinstance( + getattr(obj, attr), (str, int, float, bool, list, dict, type(None)) + ) + } + elif isinstance(obj, QtCore.QDateTime): + return obj.toString(QtCore.Qt.ISODate) + elif isinstance(obj, QtGui.QColor): + return list(obj.getRgb()) + elif isinstance(obj, tuple): + return list(obj) + else: + raise TypeError(f"Object of type {type(obj)} is not JSON serializable") + + +def serialize_settings(settings_list): + """ + Serialize settings to JSON string using a custom default serializer. + """ + try: + settings = {} + if isinstance(settings_list, str): + settings = json.loads(settings_list) + if isinstance(settings_list, list): + settings = settings_list[0] if settings_list else {} + if not isinstance(settings_list, dict): + return {} + return settings + except Exception as e: + logging.error("Deserialization failed: %s", e) + return {} + + +def restore_view_settings(flight_track_name): + """ + Restore view settings from the JSON file and adjust waypoints' flightlevel. + """ + default_settings = { + "global": {"mss_version": str(mss_version), "flight_track_name": "Unknown"}, + "views": [] + } + + config_path = Path(constants.MSUI_CONFIG_PATH) + save_path = config_path / "view_settings.json" + if not save_path.exists(): + return default_settings + + try: + with save_path.open("r", encoding="utf-8") as f: + settings = json.load(f) + + setting_data = settings.get(flight_track_name, default_settings) + if not isinstance(setting_data, dict): + logging.warning("Invalid settings data for %s; returning default", flight_track_name) + return default_settings + + views_data = setting_data.get("views", []) + + if isinstance(views_data, dict): + setting_data["views"] = [views_data] + elif not isinstance(views_data, list): + logging.warning("Invalid views format for %s, converting to empty list", flight_track_name) + setting_data["views"] = [] + + return setting_data + except Exception as e: + logging.error("Failed to restore view settings for %s: %s", flight_track_name, str(e)) + return default_settings + + +def is_flight_track_stored(flight_track): + """Check if the active flight track is saved (has a valid filename).""" + if flight_track is None: + return False + return flight_track.filename is not None diff --git a/tests/_test_mscolab/test_migrations.py b/tests/_test_mscolab/test_migrations.py index 71464211c..0668bdcb0 100644 --- a/tests/_test_mscolab/test_migrations.py +++ b/tests/_test_mscolab/test_migrations.py @@ -115,8 +115,9 @@ def test_upgrade_from(revision, iterations, mscolab_app, tmp_path): flask_migrate.check(directory=migrations_path) actual_data = {name: db.session.execute(table.select()).all() for name, table in db.metadata.tables.items()} # Check that all tables have the right number of entries with matching ids copied over - assert {k: [e[0] for e in v] for k, v in expected_data.items()} == { - k: [e[0] for e in v] for k, v in actual_data.items() + common_keys = set(expected_data.keys()) & set(actual_data.keys()) + assert {k: [e[0] for e in expected_data[k]] for k in common_keys} == { + k: [e[0] for e in actual_data[k]] for k in common_keys } # TODO: Maybe add more asserts? Basically anything could break with future migrations though, if the schema # is fundamentally changed. Having an id as the first column is already an assumption that might not always diff --git a/tests/_test_mscolab/test_server.py b/tests/_test_mscolab/test_server.py index 82ae1c2f6..b09d6d84d 100644 --- a/tests/_test_mscolab/test_server.py +++ b/tests/_test_mscolab/test_server.py @@ -33,7 +33,7 @@ from PIL import Image from mslib.mscolab.conf import mscolab_settings -from mslib.mscolab.models import User, Operation +from mslib.mscolab.models import User, Operation, ViewSettings from mslib.mscolab.server import check_login, register_user from mslib.mscolab.file_manager import FileManager from mslib.mscolab.seed import add_user, get_user @@ -555,3 +555,78 @@ def _upload_profile_image(self, test_client, token, email): } response = test_client.post('/upload_profile_image', data=data) return response + + def test_save_operation_view_settings(self): + assert add_user(self.userdata[0], self.userdata[1], self.userdata[2], self.userdata[3]) + with self.app.test_client() as test_client: + self.user = get_user(self.userdata[0]) + operation, token = self._create_operation(test_client, self.userdata) + settings = { + "global": { + "op_id": operation.id, + "user_id": self.user.id, + "operation_name": "firstflight" + }, + "views": [ + { + "view_type": "topview", + "map_section": "00 global (cyl)", + "projection": "EPSG:4326", + "extent": { + "lon_min": -180.0, + "lon_max": 180.0, + "lat_min": -90.0, + "lat_max": 90.0 + }, + "line_style": "Solid", + "line_thickness": 5.0, + "line_transparency": 1.0, + "os_screen_region": [199, 268, 952, 782] + } + ], + "wms": { + "url": "http://open-mss.org/", + "layer": "ecmwf_EUR_LL015.PLW01", + "level": "150.0", + "styles": "", + "init_time": "2012-10-17T12:00:00Z", + "valid_time": "2012-10-17T12:00:00Z" + } + } + # save settings in database + response = test_client.post('/save_operation_view_settings', data={ + "token": token, + "view_settings": json.dumps(settings) + }) + + assert response.status_code == 200, f"Expected status code 200, got {response.status_code}" + data = json.loads(response.data.decode('utf-8')) + assert data["success"] is True, f"Expected success=True, got {data}" + # Verify database state + assert self.fm.save_view_settings(operation.id, self.user, settings) + saved_settings = ViewSettings.query.filter_by(op_id=operation.id, u_id=self.user.id).first() + assert saved_settings is not None + + # Check if stored settings match input + stored = json.loads(saved_settings.settings) + assert stored is not None + assert stored["views"][0]["line_thickness"] == 5.0 + assert stored["views"][0]["view_type"] == "topview" + assert stored["wms"]["url"] == settings["wms"]["url"] + assert stored["wms"]["level"] == settings["wms"]["level"] + + # get the settings from database + response = test_client.get('/get_operation_view_settings', data={ + "op_id": operation.id + }) + + assert self.fm.get_view_settings(operation.id, self.user) + settings_result = self.fm.get_view_settings(operation.id, self.user) + assert settings_result is not None, "No settings returned" + assert settings_result[0] is True, f"Failed to retrieve view settings: {settings_result[1]}" + setting = settings_result[2] # Extract settings dictionary from tuple + assert isinstance(setting, dict), f"Expected dictionary, got {type(setting)}" + assert setting["views"][0]["line_thickness"] == 5.0, "Incorrect line_thickness" + assert setting["views"][0]["view_type"] == "topview", "Incorrect view_type" + assert setting["wms"]["url"] == settings["wms"]["url"], "Incorrect WMS URL" + assert setting["wms"]["level"] == settings["wms"]["level"], "Incorrect WMS level" diff --git a/tests/_test_msui/test_mscolab.py b/tests/_test_msui/test_mscolab.py index 6172eb438..d4cf1b991 100644 --- a/tests/_test_msui/test_mscolab.py +++ b/tests/_test_msui/test_mscolab.py @@ -45,6 +45,7 @@ from mslib.msui import msui from mslib.msui import mscolab from mslib.mscolab.seed import add_user, get_user, add_operation, add_user_to_operation +from mslib.msui import flighttrack as ft class Test_Mscolab_connect_window: @@ -1109,3 +1110,101 @@ def _activate_flight_track_at_index(self, index): point = self.window.listFlightTracks.visualItemRect(item).center() QtTest.QTest.mouseClick(self.window.listFlightTracks.viewport(), QtCore.Qt.LeftButton, pos=point) QtTest.QTest.mouseDClick(self.window.listFlightTracks.viewport(), QtCore.Qt.LeftButton, pos=point) + + def test_switch_within_operations(self, qtbot): + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something", "Test User") + self._create_operation(qtbot, "op_1", "Description op_1") + assert self.window.listOperationsMSC.model().rowCount() == 1 + self._activate_operation_at_index(0) + self.window.actionTableView.trigger() + self.window.actionLinearView.trigger() + assert len(self.window.get_active_views()) == 2 + + self._create_operation(qtbot, "op_2", "Description op_2") + assert self.window.listOperationsMSC.model().rowCount() == 2 + self._activate_operation_at_index(1) + self.window.actionTableView.trigger() + self.window.actionLinearView.trigger() + assert len(self.window.get_active_views()) == 4 + + self.window.mscolab.logout() + modify_config_file({"restore_views": True}) + self._connect_to_mscolab(qtbot) + self._login(qtbot, emailid="something@something.org", password="something") + assert self.window.listOperationsMSC.model().rowCount() == 2 + + self._activate_operation_at_index(0) + assert len(self.window.get_active_views()) == 2 + + self._activate_operation_at_index(1) + assert len(self.window.get_active_views()) == 4 + view1 = self.window.get_active_views()[0] + view1.handle_force_close() + + self._activate_operation_at_index(0) + assert len(self.window.get_active_views()) == 2 + + self._activate_operation_at_index(1) + assert len(self.window.get_active_views()) == 3 + + self._activate_operation_at_index(0) + self.window.actionTopView.trigger() + + self._activate_operation_at_index(1) + assert len(self.window.get_active_views()) == 3 + + self._activate_operation_at_index(0) + assert len(self.window.get_active_views()) == 3 + + self.window.hide() + + def test_switch_local_and_operation_with_views(self, qtbot, mscolab_server, tmp_path): + # --- Setup config with restore_view enabled --- + modify_config_file({"restore_views": True}) + + flight_track = self.window.active_flight_track + assert flight_track.name == "new flight track (1)" + flight_track.insertRows(0, 2, waypoints=[ + ft.Waypoint(lat=22.44, lon=86.67, location="point1"), + ft.Waypoint(lat=67.77, lon=48.67, location="point2"), + ]) + flight_track.name = "MyDefaultFlightTrack" + filepath = tmp_path / "MyDefaultFlightTrack.ftml" + flight_track.save_to_ftml(str(filepath)) + self.window.listFlightTracks.item(0).setText("MyDefaultFlightTrack") + + assert self.window.listFlightTracks.count() == 1 + assert self.window.listFlightTracks.item(0).text() == "MyDefaultFlightTrack" + + self.window.create_view("tableview", flight_track) + self.window.create_view("sideview", flight_track) + self.window.create_view("linearview", flight_track) + assert self.window.listViews.count() == 3 + + self._connect_to_mscolab(qtbot) + modify_config_file({"MSS_auth": {self.url: "something@something.org"}}) + self._create_user(qtbot, "something", "something@something.org", "something", "Test User") + self._create_operation(qtbot, "op_1", "Description op_1") + assert self.window.listOperationsMSC.model().rowCount() == 1 + self._activate_operation_at_index(0) + self.window.actionTableView.trigger() + self.window.actionLinearView.trigger() + assert len(self.window.get_active_views()) == 2 + + # --- Switch back to local flight track --- + self.window.update_treewidget_op_fl("flighttrack", flight_track.name) + + # After switching back, views should still be restored + assert self.window.active_flight_track.name == "MyDefaultFlightTrack" + assert self.window.listViews.count() == 3 + + self._connect_to_mscolab(qtbot) + self._login(qtbot, emailid="something@something.org", password="something") + assert self.window.listOperationsMSC.model().rowCount() == 1 + + self._activate_operation_at_index(0) + assert len(self.window.get_active_views()) == 2 + + self.window.hide() diff --git a/tests/_test_msui/test_msui.py b/tests/_test_msui/test_msui.py index 2fbeeb7ac..b3579cc4d 100644 --- a/tests/_test_msui/test_msui.py +++ b/tests/_test_msui/test_msui.py @@ -26,10 +26,13 @@ """ +import re import mock import os import argparse import pytest +import json +from tests import constants from pathlib import Path from urllib.request import urlopen from PyQt5 import QtWidgets, QtTest @@ -38,8 +41,10 @@ from mslib.msui import msui from mslib.msui import msui_mainwindow as msui_mw from tests.utils import ExceptionMock -from mslib.utils.config import read_config_file -import re +from mslib.utils.config import read_config_file, config_loader +from mslib.msui import flighttrack as ft +from mslib.msui.topview import MSUITopViewWindow +from mslib.msui.msui_mainwindow import QActiveViewsListWidgetItem def test_main(): @@ -364,3 +369,231 @@ def test_flight_track_io(self, mockload, mocksave, mockq, mocki, mockw): assert self.window.listFlightTracks.count() == 2 assert os.path.exists(self.save_ftml) os.remove(self.save_ftml) + + +class Test_MSUIMainWindow: + def test_storing_and_restoring(self, qtbot, mswms_server, tmp_path): + """Test the full scenario: create flight track, open TopView, modify settings, + save on close, and restore settings on reopen.""" + + window = msui_mw.MSUIMainWindow() + window.show() + + # create 1st flighttrack + window.create_new_flight_track() + assert window.listFlightTracks.count() == 1 + flight_track = window.active_flight_track + assert "new flight track (1)" in window.activated_flight_tracks + + flight_track_file1 = tmp_path / "flight_track1.ftml" + flight_track.filename = str(flight_track_file1) + flight_track.save_to_ftml(str(flight_track_file1)) + + waypoint_model = flight_track + waypoint_model.insertRows(0, 2, waypoints=[ + ft.Waypoint(lat=34.44, lon=56.67, location="point1"), + ft.Waypoint(lat=77.77, lon=98.67, location="point2") + ]) + + # Open top view for first flight track + window.create_view("topview", flight_track) + assert window.listViews.count() == 1 + top_view1 = window.listViews.item(0).window + assert top_view1.view_type == "Top View" + + top_view1.cbChangeMapSection.setCurrentText("00 global (cyl)") + wms_settings1 = { + "url": mswms_server, + "layer": "ecmwf_EUR_LL015.PLRelHum01", + "level": "200.0", + "styles": "", + "init_time": "2012-10-17T12:00:00Z", + "valid_time": "2012-10-17T12:00:00Z", + } + qtbot.wait(1000) + top_view1.restore_wms_settings(wms_settings1) + qtbot.waitUntil( + lambda: top_view1.wms_control.multilayers.cbWMS_URL.currentText().rstrip('/') == mswms_server, + timeout=500 + ) + + # create 2nd flighttrack + window.create_new_flight_track() + assert window.listFlightTracks.count() == 2 + flight_track2 = window.active_flight_track + assert flight_track2.name == "new flight track (2)" + assert "new flight track (2)" in window.activated_flight_tracks + + flight_track_file2 = tmp_path / "flight_track2.ftml" + flight_track2.filename = str(flight_track_file2) + flight_track2.save_to_ftml(str(flight_track_file2)) + + waypoint_model2 = flight_track2 + waypoint_model2.insertRows(0, 2, waypoints=[ + ft.Waypoint(lat=22.44, lon=86.67, location="point1"), + ft.Waypoint(lat=67.77, lon=48.67, location="point2") + ]) + + assert window.listViews.count() == 1 + top_view2_1 = window.listViews.item(0).window + assert top_view2_1.view_type == "Top View" + + window.create_view("topview", flight_track2) + assert window.listViews.count() == 2 + top_view2_2 = window.listViews.item(1).window + assert top_view2_2.view_type == "Top View" + + top_view2_2.cbChangeMapSection.setCurrentText("00 global (cyl)") + wms_settings2 = { + "url": mswms_server, + "layer": "ecmwf_EUR_LL015.PLW01", + "level": "250.0", + "styles": "", + "init_time": "2012-10-17T12:00:00Z", + "valid_time": "2012-10-17T12:00:00Z", + } + qtbot.wait(1000) + top_view2_2.restore_wms_settings(wms_settings2) + qtbot.waitUntil( + lambda: top_view2_2.wms_control.multilayers.cbWMS_URL.currentText().rstrip('/') == mswms_server, + timeout=500) + + with mock.patch("PyQt5.QtWidgets.QMessageBox.warning", return_value=QtWidgets.QMessageBox.Yes): + window.close() + + # Assert: Verify view_settings.json after closing + config_path = Path(constants.MSUI_CONFIG_PATH) + settings_file = config_path / "view_settings.json" + assert settings_file.exists(), f"view_settings.json not found at {settings_file}" + with settings_file.open("r") as f: + settings_data = json.load(f) + + assert "flight_track1" in settings_data + assert len(settings_data["flight_track1"]["views"]) == 1 + assert settings_data["flight_track1"]["views"][0]["view_type"] == "topview" + assert settings_data["flight_track1"]["views"][0]["wms"]["url"].rstrip('/') == mswms_server + assert "flight_track2" in settings_data + assert len(settings_data["flight_track2"]["views"]) == 2 + assert settings_data["flight_track2"]["views"][0]["view_type"] == "topview" + assert settings_data["flight_track2"]["views"][0]["wms"]["url"].rstrip('/') == mswms_server + assert settings_data["flight_track2"]["views"][1]["view_type"] == "topview" + assert settings_data["flight_track2"]["views"][1]["wms"]["url"].rstrip('/') == mswms_server + + # Create MSUIMainWindow + new_window = msui_mw.MSUIMainWindow() + new_window.show() + test_config_path = config_path / "msui_settings.json" + initial_config = { + "restore_views": True + } + test_config_path.write_text(json.dumps(initial_config, indent=4)) + read_config_file(path=str(test_config_path)) + with test_config_path.open() as f: + file_content = json.load(f) + assert file_content["restore_views"] is True + assert config_loader(dataset="restore_views") is True + + new_window.create_new_flight_track() + new_window.active_flight_track.name = "flight_track1" + new_window.active_flight_track.filename = str(flight_track_file1) + new_window.active_flight_track.load_from_ftml(str(flight_track_file1)) + qtbot.wait(1000) + + while new_window.listViews.count() > 0: + new_window.listViews.item(0).window.handle_force_close() + QActiveViewsListWidgetItem.opened_views = 0 + qtbot.wait(1000) + new_window.restore_views_for_active_flighttrack() + assert new_window.active_flight_track.name == "flight_track1" + assert new_window.active_flight_track.filename == str(flight_track_file1) + assert new_window.listViews.count() == 1 + assert new_window.listFlightTracks.count() == 1 + + # # Access restored view + restored_top_view1 = new_window.listViews.item(0) + assert restored_top_view1 is not None, "No view restored" + restored_top_view1 = restored_top_view1.window + assert isinstance(restored_top_view1, MSUITopViewWindow) + # qtbot.wait(1000) + + # Verify WMS settings + wms_control1 = restored_top_view1.wms_control + wms_control1.get_capabilities() + assert wms_control1.multilayers.cbWMS_URL.currentText().rstrip("/") == mswms_server + qtbot.wait(1000) + + # Load second flight track + new_window.create_new_flight_track() + new_window.active_flight_track.name = "flight_track2" + new_window.active_flight_track.filename = str(flight_track_file2) + new_window.active_flight_track.load_from_ftml(str(flight_track_file2)) + + # Verify WMS settings + while new_window.listViews.count() > 0: + new_window.listViews.item(0).window.handle_force_close() + QActiveViewsListWidgetItem.opened_views = 0 + qtbot.wait(1000) + new_window.restore_views_for_active_flighttrack() + assert new_window.listFlightTracks.count() == 2 + assert new_window.active_flight_track.name == "flight_track2" + assert new_window.active_flight_track.filename == str(flight_track_file2) + assert new_window.listViews.count() == 2 + restored_top_view2_1 = new_window.listViews.item(0).window + assert isinstance(restored_top_view2_1, MSUITopViewWindow) + + restored_top_view2_1 = new_window.listViews.item(0).window + assert isinstance(restored_top_view2_1, MSUITopViewWindow) + + wms_control2_1 = restored_top_view2_1.wms_control + wms_control2_1.get_capabilities() + assert wms_control2_1.multilayers.cbWMS_URL.currentText().rstrip("/") == mswms_server, \ + f"Expected URL {mswms_server}, got {wms_control2_1.multilayers.cbWMS_URL.currentText()}" + qtbot.wait(1000) + + restored_top_view2_2 = new_window.listViews.item(1).window + assert isinstance(restored_top_view2_2, MSUITopViewWindow) + wms_control2_2 = restored_top_view2_2.wms_control + wms_control2_2.get_capabilities() + qtbot.wait(1000) + assert wms_control2_2.multilayers.cbWMS_URL.currentText().rstrip("/") == mswms_server, \ + f"Expected URL {mswms_server}, got {wms_control2_2.multilayers.cbWMS_URL.currentText()}" + qtbot.wait(1000) + assert wms_control2_2.multilayers.cbWMS_URL.currentText().rstrip("/") == mswms_server, \ + f"Expected URL {mswms_server}, got {wms_control2_2.multilayers.cbWMS_URL.currentText()}" + + file_content["restore_views"] = False + new_window.hide() + + def test_flightrack_before_restoreview_enable(self, qtbot): + """Test flightrack before restore_views enable""" + + window = msui_mw.MSUIMainWindow() + window.show() + + window.create_new_flight_track() + assert window.listFlightTracks.count() == 1 + + flight_track = window.active_flight_track + waypoint_model = flight_track + waypoint_model.insertRows(0, 2, waypoints=[ + ft.Waypoint(lat=34.44, lon=56.67, location="point1"), + ft.Waypoint(lat=77.77, lon=98.67, location="point2") + ]) + + window.create_view("topview", flight_track) + assert window.listViews.count() == 1 + + window.create_new_flight_track() + assert window.listFlightTracks.count() == 2 + flight_track2 = window.active_flight_track + + waypoint_model2 = flight_track2 + waypoint_model2.insertRows(0, 2, waypoints=[ + ft.Waypoint(lat=22.44, lon=86.67, location="point1"), + ft.Waypoint(lat=67.77, lon=48.67, location="point2") + ]) + + assert window.listViews.count() == 1 + window.create_view("topview", flight_track2) + assert window.listViews.count() == 2 + window.hide()