Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ on:
- develop
- stable
- 'GSOC**'
- 'prepare**'
pull_request:
branches:
- develop
- stable
- 'GSOC**'
- 'prepare**'

jobs:
codespell:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/testing-all-oses.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ on:
- develop
- stable
- 'GSOC**'
- 'prepare**'
pull_request:
branches:
- develop
- stable
- 'GSOC**'
- 'prepare**'

jobs:
test:
Expand Down
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ in alphabetic order by first name
- Anveshan Lal <anveshanrx8@gmail.com>
- Aravind Murali <aravindmurali711@gmail.com>
- Aryan Gupta <work.aryangupta@gmail.com>
- Annapurna Gupta <ganu48600@gmail.com>
- Christian Rolf <c.rolf@fz-juelich.de>
- Debajyoti Dasgupta <debajyotidasgupta6@gmail.com>
- Hrithik Kumar Verma <vermahrithik812@gmail.com>
Expand Down
44 changes: 44 additions & 0 deletions docs/View Layout and Restoring .rst
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/components.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Components
plugins
mswms
mscolab
view layout and restoring
gentutorials
mssautoplot
autoplot_dock_widget
Expand Down
129 changes: 128 additions & 1 deletion mslib/mscolab/file_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)}", {}
63 changes: 63 additions & 0 deletions mslib/mscolab/migrations/versions/2daa5c5142a1_new_models.py
Original file line number Diff line number Diff line change
@@ -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 ###
55 changes: 55 additions & 0 deletions mslib/mscolab/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading