Skip to content
Open
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
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
9 changes: 9 additions & 0 deletions docs/mscolab.rst
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ Steps to Run MSColab Server
- To start the server run :code:`mscolab start`.
- If you ever want to reset or add dummy data to your database you can use the commands :code:`mscolab db --reset` and :code:`mscolab db --seed` respectively.

Both commands support a :code:`--yes` (or :code:`-y`) flag to skip the interactive
confirmation prompt. This is useful for non-interactive usage such as CI pipelines
or tutorial scripts.

Examples::

mscolab db --reset --yes
mscolab db --seed --yes



Notes for server administrators
Expand Down
14 changes: 13 additions & 1 deletion mslib/mscolab/file_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ def save_user_profile_image(self, user_id, image_file):
"""
relative_file_path = self.upload_file(image_file, subfolder='profile', identifier=user_id)

user = User.query.get(user_id)
user = db.session.get(User, user_id)
if user:
if user.profile_image_path:
# Delete the previous image
Expand All @@ -341,6 +341,18 @@ def save_user_profile_image(self, user_id, image_file):
else:
return False, "User not found"

def get_user_profile_image(self, user_id):
"""
Retrieve the user's profile image from the database.
"""
user = db.session.get(User, user_id)
if user:
if user.profile_image_path:
return True, user.profile_image_path
return False, "Profile image not found"
else:
return False, "User not found"

def update_operation(self, op_id, attribute, value, user):
"""
op_id: operation id
Expand Down
62 changes: 42 additions & 20 deletions mslib/mscolab/mscolab.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ def handle_start(args=None):
start_server(APP, sockio, cm, fm)


def confirm_action(confirmation_prompt):
def confirm_action(confirmation_prompt, assume_yes=False):
if assume_yes:
return True

while True:
confirmation = input(confirmation_prompt).lower()
if confirmation == "n" or confirmation == "":
Expand Down Expand Up @@ -375,17 +378,27 @@ def main():
default=None)

database_parser = subparsers.add_parser("db", help="Manage mscolab database")
database_parser = database_parser.add_mutually_exclusive_group(required=True)
database_parser.add_argument("--reset", help="Reset database", action="store_true")
database_parser.add_argument("--seed", help="Seed database", action="store_true")
database_parser.add_argument("--users_by_file", type=argparse.FileType('r'),
help="adds users into database, fileformat: suggested_username name <email>")
database_parser.add_argument("--delete_users_by_file", type=argparse.FileType('r'),
help="removes users from the database, fileformat: email")
database_parser.add_argument("--default_operation", help="adds all users into a default TEMPLATE operation",
action="store_true")
database_parser.add_argument("--add_all_to_all_operation", help="adds all users into all other operations",
action="store_true")

db_actions = database_parser.add_mutually_exclusive_group(required=True)
db_actions.add_argument("--reset", help="Reset database", action="store_true")
db_actions.add_argument("--seed", help="Seed database", action="store_true")
db_actions.add_argument("--users_by_file", type=argparse.FileType("r"),
help="adds users into database, fileformat: suggested_username name <email>")
db_actions.add_argument("--delete_users_by_file", type=argparse.FileType("r"),
help="removes users from the database, fileformat: email")
db_actions.add_argument("--default_operation",
help="adds all users into a default TEMPLATE operation",
action="store_true")
db_actions.add_argument("--add_all_to_all_operation",
help="adds all users into all other operations",
action="store_true")

database_parser.add_argument(
"-y", "--yes",
action="store_true",
help="Skip confirmation prompt"
)

sso_conf_parser = subparsers.add_parser("sso_conf", help="single sign on process configurations")
sso_conf_parser = sso_conf_parser.add_mutually_exclusive_group(required=True)
sso_conf_parser.add_argument("--init_sso_crts",
Expand Down Expand Up @@ -418,20 +431,26 @@ def main():

elif args.action == "db":
if args.reset:
confirmation = confirm_action("Are you sure you want to reset the database? This would delete "
"all your data! (y/[n]):")
confirmation = confirm_action(
"Are you sure you want to reset the database? This would delete "
"all your data! (y/[n]):",
assume_yes=args.yes)
if confirmation is True:
with APP.app_context():
handle_db_reset()
elif args.seed:
confirmation = confirm_action("Are you sure you want to seed the database? Seeding will delete all your "
"existing data and replace it with seed data (y/[n]):")
confirmation = confirm_action(
"Are you sure you want to seed the database? Seeding will delete all your "
"existing data and replace it with seed data (y/[n]):",
assume_yes=args.yes)
if confirmation is True:
with APP.app_context():
handle_db_seed()
elif args.users_by_file is not None:
# fileformat: suggested_username name <email>
confirmation = confirm_action("Are you sure you want to add users to the database? (y/[n]):")
confirmation = confirm_action(
"Are you sure you want to add users to the database? (y/[n]):",
assume_yes=args.yes)
if confirmation is True:
for line in args.users_by_file.readlines():
info = line.split()
Expand All @@ -442,19 +461,22 @@ def main():
add_user(emailid, username, password, fullname)
elif args.default_operation:
confirmation = confirm_action(
"Are you sure you want to add users to the default TEMPLATE operation? (y/[n]):")
"Are you sure you want to add users to the default TEMPLATE operation? (y/[n]):",
assume_yes=args.yes)
if confirmation is True:
# adds all users as collaborator on the operation TEMPLATE if not added, command can be repeated
add_all_users_default_operation(access_level='admin')
elif args.add_all_to_all_operation:
confirmation = confirm_action(
"Are you sure you want to add users to the ALL operations? (y/[n]):")
"Are you sure you want to add users to the ALL operations? (y/[n]):",
assume_yes=args.yes)
if confirmation is True:
# adds all users to all Operations
add_all_users_to_all_operations()
elif args.delete_users_by_file:
confirmation = confirm_action(
"Are you sure you want to delete a user? (y/[n]):")
"Are you sure you want to delete a user? (y/[n]):",
assume_yes=args.yes)
if confirmation is True:
# deletes users from the db
for email in args.delete_users_by_file.readlines():
Expand Down
9 changes: 4 additions & 5 deletions mslib/mscolab/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
See the License for the specific language governing permissions and
limitations under the License.
"""
from pathlib import Path
import sys
import functools
import json
Expand All @@ -35,6 +34,7 @@
import sqlalchemy.exc
import werkzeug
import flask_migrate
from pathlib import Path

from itsdangerous import URLSafeTimedSerializer, BadSignature
from flask import g, jsonify, request, render_template, flash
Expand Down Expand Up @@ -472,13 +472,12 @@ def upload_profile_image():
@verify_user
def fetch_profile_image():
user_id = request.form['user_id']
user = User.query.get(user_id)
if user and user.profile_image_path:
success, filename = fm.get_user_profile_image(user_id)
if success:
base_path = mscolab_settings.UPLOAD_FOLDER
filename = user.profile_image_path
return send_from_directory(Path(base_path), filename)
else:
abort(404)
return jsonify({'message': 'User or profile image not found'}), 404


@APP.route("/delete_own_account", methods=["POST"])
Expand Down
36 changes: 23 additions & 13 deletions mslib/msui/mpl_qtwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,10 @@ def draw_flightpath_legend(self, flightpath_dict):


class SideViewPlotter(ViewPlotter):
_pres_maj = np.concatenate([np.arange(top * 10, top, -top) for top in (10000, 1000, 100, 10)] + [[10]])
_pres_min = np.concatenate([np.arange(top * 10, top, -top // 10) for top in (10000, 1000, 100, 10)] + [[10]])
_pres_maj = np.concatenate([np.arange(top * 10, top, -top) for top in (10000, 1000, 100, 10, 1, 0.1)] +
[[0.1]])
_pres_min = np.concatenate([np.arange(top * 10, top, -top // 10) for top in (10000, 1000, 100, 10, 1, 0.1)] +
[[0.1]])

def __init__(self, fig=None, ax=None, settings=None, numlabels=None, num_interpolation_points=None):
"""
Expand Down Expand Up @@ -427,37 +429,45 @@ def _determine_ticks_labels(self, typ):
# Compute the position of major and minor ticks. Major ticks are labelled.
major_ticks = self._pres_maj[(self._pres_maj <= self.p_bot) & (self._pres_maj >= self.p_top)]
minor_ticks = self._pres_min[(self._pres_min <= self.p_bot) & (self._pres_min >= self.p_top)]
labels = [f"{int(_x / 100)}"
if (_x / 100) - int(_x / 100) == 0 else f"{float(_x / 100)}" for _x in major_ticks]
if len(labels) > 20:
labels = ["" if x.split(".")[-1][0] in "975" else x for x in labels]
labels = [f"{_x / 100:.0f}" if _x / 100 >= 1 else (
f"{_x / 100:.1f}" if _x / 100 >= 0.1 else (
f"{_x / 100:.2f}" if _x / 100 >= 0.01 else (
f"{_x / 100:.3f}"))) for _x in major_ticks]
if len(labels) > 40:
labels = ["" if any(y in x for y in "9865") else x for x in labels]
elif len(labels) > 20:
labels = ["" if any(y in x for y in "975") else x for x in labels]
elif len(labels) > 10:
labels = ["" if x.split(".")[-1][0] in "9" else x for x in labels]
labels = ["" if "9" in x else x for x in labels]
ylabel = "pressure (hPa)"
elif typ == "pressure altitude":
bot_km = thermolib.pressure2flightlevel(self.p_bot * units.Pa).to(units.km).magnitude
top_km = thermolib.pressure2flightlevel(self.p_top * units.Pa).to(units.km).magnitude
ma_dist, mi_dist = 4, 1.0
ma_dist, mi_dist = 5, 1.0
if (top_km - bot_km) <= 20:
ma_dist, mi_dist = 1, 0.5
elif (top_km - bot_km) <= 40:
ma_dist, mi_dist = 2, 0.5
major_heights = np.arange(0, top_km + 1, ma_dist)
minor_heights = np.arange(0, top_km + 1, mi_dist)
elif (top_km - bot_km) <= 60:
ma_dist, mi_dist = 4, 1.0
major_heights = np.arange(0, top_km + 0.1, ma_dist)
minor_heights = np.arange(0, top_km + 0.1, mi_dist)
major_ticks = thermolib.flightlevel2pressure(major_heights * units.km).magnitude
minor_ticks = thermolib.flightlevel2pressure(minor_heights * units.km).magnitude
labels = major_heights
ylabel = "pressure altitude (km)"
elif typ == "flight level":
bot_km = thermolib.pressure2flightlevel(self.p_bot * units.Pa).to(units.km).magnitude
top_km = thermolib.pressure2flightlevel(self.p_top * units.Pa).to(units.km).magnitude
ma_dist, mi_dist = 50, 10
ma_dist, mi_dist = 100, 20
if (top_km - bot_km) <= 10:
ma_dist, mi_dist = 20, 10
elif (top_km - bot_km) <= 40:
ma_dist, mi_dist = 40, 10
major_fl = np.arange(0, 2132, ma_dist)
minor_fl = np.arange(0, 2132, mi_dist)
elif (top_km - bot_km) <= 60:
ma_dist, mi_dist = 50, 10
major_fl = np.arange(0, 3248, ma_dist)
minor_fl = np.arange(0, 3248, mi_dist)
major_ticks = thermolib.flightlevel2pressure(major_fl * units.hft).magnitude
minor_ticks = thermolib.flightlevel2pressure(minor_fl * units.hft).magnitude
labels = major_fl
Expand Down
1 change: 1 addition & 0 deletions mslib/msui/qt5/ui_sideview_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def setupUi(self, SideViewOptionsDialog):
self.sbPtop.setSizePolicy(sizePolicy)
self.sbPtop.setMinimum(0.0)
self.sbPtop.setMaximum(2132.0)
self.sbPtop.setDecimals(4)
self.sbPtop.setProperty("value", 200.0)
self.sbPtop.setObjectName("sbPtop")
self.horizontalLayout.addWidget(self.sbPtop)
Expand Down
11 changes: 6 additions & 5 deletions mslib/msui/sideview.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,16 @@ def onTransparencyChanged(self, value):
self.line_transparency = value / 100

def setBotTopLimits(self, axis_type):
bot, top = {
"maximum": (0, 2132),
"pressure": (0.1, 1050),
"pressure altitude": (0, 65),
"flight level": (0, 2132),
bot, top, dec = {
"maximum": (0, 3248, 4),
"pressure": (0.0003, 1050, 4),
"pressure altitude": (0, 99.9, 1),
"flight level": (0, 3248, 0),
}[axis_type]
for button in (self.sbPbot, self.sbPtop):
button.setMinimum(bot)
button.setMaximum(top)
button.setDecimals(dec)

def setColour(self, which):
"""
Expand Down
Loading
Loading