From 2d629557407a37f934f77871ef12065fb12de55d Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Tue, 26 May 2026 16:19:25 +0200 Subject: [PATCH 01/14] added decorator for edit and read viewer rights --- app/projects/decorators.py | 45 ++++++++++++++++++++++++++++++++++++++ app/projects/views.py | 7 +----- 2 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 app/projects/decorators.py diff --git a/app/projects/decorators.py b/app/projects/decorators.py new file mode 100644 index 00000000..bb26794e --- /dev/null +++ b/app/projects/decorators.py @@ -0,0 +1,45 @@ +from django.core.exceptions import PermissionDenied +from django.shortcuts import get_object_or_404 + +from functools import wraps + +from projects.models import Project, Scenario + + +def viewer_has_view_rights(view_func): + @wraps(view_func) + def _wrapped_view(request, proj_id=None, scenario_id=None, *args, **kwargs): + if proj_id: + project = get_object_or_404(Project, pk=proj_id) + elif scenario_id: + scenario = get_object_or_404(Scenario, pk=scenario_id) + project = scenario.project + # oder ersetzen durch user.has_read_rights + if (project.user != request.user) and ( + project.viewers.filter(user__email=request.user.email).exists() is False + ): + raise PermissionDenied + return view_func(request, proj_id, *args, **kwargs) + + return _wrapped_view + + +def viewer_has_edit_rights(view_func): + @wraps(view_func) + def _wrapped_view(request, proj_id=None, scenario_id=None, *args, **kwargs): + if proj_id: + project = get_object_or_404(Project, pk=proj_id) + elif scenario_id: + scenario = get_object_or_404(Scenario, pk=scenario_id) + project = scenario.project + # oder ersetzen durch user.has_edit_rights + if (project.user != request.user) and ( + project.viewers.filter( + user__email=request.user.email, share_rights="edit" + ).exists() + is False + ): + raise PermissionDenied + return view_func(request, proj_id, *args, **kwargs) + + return _wrapped_view diff --git a/app/projects/views.py b/app/projects/views.py index 4acf2662..089def1e 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -69,6 +69,7 @@ MaxEmissionConstraint, NZEConstraint, ) +from projects.decorators import viewer_has_view_rights, viewer_has_edit_rights from dashboard.models import FancyResults from .scenario_topology_helpers import ( handle_storage_unit_form_post, @@ -298,12 +299,6 @@ def ajax_project_viewers_form(request): @require_http_methods(["GET"]) def project_detail(request, proj_id): project = get_object_or_404(Project, pk=proj_id) - - if (project.user != request.user) and ( - project.viewers.filter(user__email=request.user.email).exists() is False - ): - raise PermissionDenied - logger.info(f"Populating project and economic details in forms.") project_form = ProjectDetailForm(None, instance=project) economic_data_form = EconomicDataDetailForm(None, instance=project.economic_data) From cd0d7db668744764f909534d3ea680abe6c1b3ab Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Tue, 26 May 2026 17:15:00 +0200 Subject: [PATCH 02/14] exchanged repeating check for viewer share rights with decorator in projects for simpler views --- app/projects/decorators.py | 12 ++++---- app/projects/views.py | 59 ++++++-------------------------------- 2 files changed, 14 insertions(+), 57 deletions(-) diff --git a/app/projects/decorators.py b/app/projects/decorators.py index bb26794e..7d242d98 100644 --- a/app/projects/decorators.py +++ b/app/projects/decorators.py @@ -8,11 +8,11 @@ def viewer_has_view_rights(view_func): @wraps(view_func) - def _wrapped_view(request, proj_id=None, scenario_id=None, *args, **kwargs): + def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): if proj_id: project = get_object_or_404(Project, pk=proj_id) - elif scenario_id: - scenario = get_object_or_404(Scenario, pk=scenario_id) + elif scen_id: + scenario = get_object_or_404(Scenario, pk=scen_id) project = scenario.project # oder ersetzen durch user.has_read_rights if (project.user != request.user) and ( @@ -26,11 +26,11 @@ def _wrapped_view(request, proj_id=None, scenario_id=None, *args, **kwargs): def viewer_has_edit_rights(view_func): @wraps(view_func) - def _wrapped_view(request, proj_id=None, scenario_id=None, *args, **kwargs): + def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): if proj_id: project = get_object_or_404(Project, pk=proj_id) - elif scenario_id: - scenario = get_object_or_404(Scenario, pk=scenario_id) + elif scen_id: + scenario = get_object_or_404(Scenario, pk=scen_id) project = scenario.project # oder ersetzen durch user.has_edit_rights if (project.user != request.user) and ( diff --git a/app/projects/views.py b/app/projects/views.py index 089def1e..976631ff 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -297,6 +297,7 @@ def ajax_project_viewers_form(request): @login_required @require_http_methods(["GET"]) +@viewer_has_view_rights def project_detail(request, proj_id): project = get_object_or_404(Project, pk=proj_id) logger.info(f"Populating project and economic details in forms.") @@ -341,17 +342,10 @@ def project_create(request): @login_required @require_http_methods(["GET", "POST"]) +@viewer_has_edit_rights def project_update(request, proj_id): project = get_object_or_404(Project, id=proj_id) - if (project.user != request.user) and ( - project.viewers.filter( - user__email=request.user.email, share_rights="edit" - ).exists() - is False - ): - raise PermissionDenied - project_form = ProjectUpdateForm(request.POST or None, instance=project) economic_data_form = EconomicDataUpdateForm( request.POST or None, instance=project.economic_data @@ -591,28 +585,14 @@ def project_search(request, proj_id=None, scen_id=None): @login_required @require_http_methods(["POST"]) +@viewer_has_edit_rights def project_duplicate(request, proj_id): """Duplicates the selected project along with its associated scenarios""" project = get_object_or_404(Project, pk=proj_id) # duplicate the project dm = project.export(bind_scenario_data=True) - if (project.user == request.user) or ( - project.viewers.filter( - user__email=request.user.email, share_rights="edit" - ).exists() - is True - ): - new_proj_id = load_project_from_dict(dm, user=request.user) - else: - messages.error( - request, - _( - "You cannot duplicate a shared project without the owner granting you 'edit' rights" - ), - ) - new_proj_id = project.id - + new_proj_id = load_project_from_dict(dm, user=request.user) return HttpResponseRedirect(reverse("project_search", args=[new_proj_id])) @@ -1019,6 +999,7 @@ def scenario_create_topology(request, proj_id, scen_id, step_id=2, max_step=3): @login_required @require_http_methods(["GET", "POST"]) +@viewer_has_view_rights def scenario_create_constraints(request, proj_id, scen_id, step_id=3, max_step=4): constraints_labels = { "minimal_degree_of_autonomy": _("Minimal degree of autonomy"), @@ -1042,12 +1023,6 @@ def scenario_create_constraints(request, proj_id, scen_id, step_id=3, max_step=4 scenario = get_object_or_404(Scenario, pk=scen_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied - if (scenario.project.user != request.user) and ( scenario.project.viewers.filter( user__email=request.user.email, share_rights="edit" @@ -1140,15 +1115,10 @@ def scenario_create_constraints(request, proj_id, scen_id, step_id=3, max_step=4 @login_required @require_http_methods(["GET", "POST"]) +@viewer_has_view_rights def scenario_review(request, proj_id, scen_id, step_id=4, max_step=MAX_STEP): scenario = get_object_or_404(Scenario, pk=scen_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied - if request.method == "GET": html_template = f"scenario/simulation/no-status.html" context = { @@ -1290,16 +1260,10 @@ def scenario_steps(request, proj_id, step_id=None, scen_id=None): # TODO delete this useless code here @login_required @require_http_methods(["GET"]) +@viewer_has_view_rights def scenario_view(request, scen_id, step_id): """Scenario View. GET request only.""" scenario = get_object_or_404(Scenario, pk=scen_id) - - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied - return HttpResponseRedirect(reverse("project_search", args=[scenario.project.id])) @@ -1482,17 +1446,10 @@ def scenario_delete(request, scen_id): @login_required @require_http_methods(["POST"]) +@viewer_has_edit_rights def reset_scenario_changes(request, scen_id): scenario = get_object_or_404(Scenario, id=scen_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter( - user__email=request.user.email, share_rights="edit" - ).exists() - is False - ): - raise PermissionDenied - if request.POST: qs = ParameterChangeTracker.objects.filter(simulation=scenario.simulation) # TODO reverse the changes here From dbfc3237f801f55300b5abee941bd0feefd54388 Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 27 May 2026 11:22:18 +0200 Subject: [PATCH 03/14] fixed worng variable name in decorator --- app/projects/decorators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/projects/decorators.py b/app/projects/decorators.py index 7d242d98..00760ecc 100644 --- a/app/projects/decorators.py +++ b/app/projects/decorators.py @@ -19,7 +19,7 @@ def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): project.viewers.filter(user__email=request.user.email).exists() is False ): raise PermissionDenied - return view_func(request, proj_id, *args, **kwargs) + return view_func(request, proj_id, scen_id, *args, **kwargs) return _wrapped_view @@ -40,6 +40,6 @@ def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): is False ): raise PermissionDenied - return view_func(request, proj_id, *args, **kwargs) + return view_func(request, proj_id, scen_id, *args, **kwargs) return _wrapped_view From c26304800c3cc1a3449b116671f4c1b7bf86b7e6 Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 27 May 2026 11:54:04 +0200 Subject: [PATCH 04/14] added different return view_func behavior for views with different parameters --- app/projects/decorators.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/projects/decorators.py b/app/projects/decorators.py index 00760ecc..723ebba4 100644 --- a/app/projects/decorators.py +++ b/app/projects/decorators.py @@ -19,7 +19,13 @@ def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): project.viewers.filter(user__email=request.user.email).exists() is False ): raise PermissionDenied - return view_func(request, proj_id, scen_id, *args, **kwargs) + # check for existing parameters to handle the different view parameters + if proj_id is not None and scen_id is not None: + return view_func(request, proj_id, scen_id, *args, **kwargs) + elif proj_id is not None: + return view_func(request, proj_id, *args, **kwargs) + elif scen_id is not None: + return view_func(request, scen_id, *args, **kwargs) return _wrapped_view @@ -40,6 +46,12 @@ def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): is False ): raise PermissionDenied - return view_func(request, proj_id, scen_id, *args, **kwargs) + # check for existing parameters to handle the different view parameters + if proj_id is not None and scen_id is not None: + return view_func(request, proj_id, scen_id, *args, **kwargs) + elif proj_id is not None: + return view_func(request, proj_id, *args, **kwargs) + elif scen_id is not None: + return view_func(request, scen_id, *args, **kwargs) return _wrapped_view From 15eec754be2ce2527624abaaf2eb89029e82bc7d Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 27 May 2026 11:54:54 +0200 Subject: [PATCH 05/14] added decorators and user.has_edit_rights checks to more complex views in projects --- app/projects/views.py | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/app/projects/views.py b/app/projects/views.py index 976631ff..59e5a66b 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -754,6 +754,7 @@ def scenario_select_project(request, step_id=0, max_step=1): @login_required @require_http_methods(["GET", "POST"]) +@viewer_has_view_rights def scenario_create_parameters(request, proj_id, scen_id=None, step_id=1, max_step=2): project = get_object_or_404(Project, pk=proj_id) # all projects which the user is able to select (the one the user created) @@ -768,13 +769,6 @@ def scenario_create_parameters(request, proj_id, scen_id=None, step_id=1, max_st if request.method == "GET": if scen_id is not None: scenario = get_object_or_404(Scenario, id=scen_id) - - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied - form = ScenarioUpdateForm( None, instance=scenario, project_queryset=user_projects ) @@ -820,12 +814,7 @@ def scenario_create_parameters(request, proj_id, scen_id=None, step_id=1, max_st # Only allow edition in DB for owner or share with edit rights selected_project = form.cleaned_data["project"] - if (selected_project.user == request.user) or ( - selected_project.viewers.filter( - user__email=request.user.email, share_rights="edit" - ).exists() - is True - ): + if request.user.has_edit_rights(selected_project): qs_sim = Simulation.objects.filter(scenario__id=scenario.id) # update the parameter values which are different from existing values for name, value in form.cleaned_data.items(): @@ -935,13 +924,9 @@ def scenario_create_topology(request, proj_id, scen_id, step_id=2, max_step=3): # TODO: if the scenario exists, load it, otherwise default form scenario = get_object_or_404(Scenario, pk=scen_id) + project = get_object_or_404(Project, pk=proj_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter( - user__email=request.user.email, share_rights="edit" - ).exists() - is False - ): + if request.user.has_edit_rights(project): user_has_right_to_save = False # raise PermissionDenied else: @@ -1022,13 +1007,9 @@ def scenario_create_constraints(request, proj_id, scen_id, step_id=3, max_step=4 } scenario = get_object_or_404(Scenario, pk=scen_id) + project = get_object_or_404(Project, pk=proj_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter( - user__email=request.user.email, share_rights="edit" - ).exists() - is False - ): + if request.user.has_edit_rights(project): user_has_right_to_save = False else: user_has_right_to_save = True From 3a8c305a2195b1d4c68754942094997f4070af23 Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 27 May 2026 15:07:45 +0200 Subject: [PATCH 06/14] exchanged repeating checks for viewer share rigths with decorator in dashboard views --- app/dashboard/views.py | 84 +++++++++--------------------------------- 1 file changed, 17 insertions(+), 67 deletions(-) diff --git a/app/dashboard/views.py b/app/dashboard/views.py index c1a64f76..f458dbe0 100644 --- a/app/dashboard/views.py +++ b/app/dashboard/views.py @@ -36,6 +36,7 @@ get_selected_scenarios_in_cache, ) +from projects.decorators import viewer_has_edit_rights, viewer_has_view_rights from projects.forms import BusForm, AssetCreateForm, StorageForm from projects.constants import COMPARE_VIEW, STEP_LIST, MAX_STEP @@ -72,13 +73,9 @@ @login_required @json_view @require_http_methods(["GET"]) +@viewer_has_view_rights def scenario_available_results(request, scen_id): scenario = get_object_or_404(Scenario, pk=scen_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied try: assets_results_obj = AssetsResults.objects.get(simulation=scenario.simulation) @@ -174,9 +171,7 @@ def scenario_visualize_results( ) else: project = get_object_or_404(Project, id=proj_id) - if (project.user != request.user) and ( - project.viewers.filter(user__email=request.user.email).exists() is False - ): + if not request.user.has_read_rights(project): raise PermissionDenied selected_scenarios = get_selected_scenarios_in_cache(request, proj_id) @@ -271,6 +266,7 @@ def scenario_visualize_results( @login_required @require_http_methods(["POST", "GET"]) +@viewer_has_view_rights def project_compare_results(request, proj_id, step_id=5, max_step=MAX_STEP): request.session[COMPARE_VIEW] = True user_projects = fetch_user_projects(request.user) @@ -281,10 +277,6 @@ def project_compare_results(request, proj_id, step_id=5, max_step=MAX_STEP): ) project = get_object_or_404(Project, id=proj_id) - if (project.user != request.user) and ( - project.viewers.filter(user__email=request.user.email).exists() is False - ): - raise PermissionDenied user_scenarios = project.get_scenarios_with_results() report_items_data = [ @@ -340,10 +332,7 @@ def project_sensitivity_analysis(request, proj_id, sa_id=None): ) else: project = get_object_or_404(Project, id=proj_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): + if not request.user.has_read_rights(project): raise PermissionDenied user_sa = get_project_sensitivity_analysis(project) @@ -1002,6 +991,7 @@ def view_asset_parameters(request, scen_id, asset_type_name, asset_uuid): @login_required @json_view @require_http_methods(["GET"]) +@viewer_has_view_rights def scenario_economic_results(request, scen_id=None): """ This view gathers all simulation specific cost matrix KPI results @@ -1017,14 +1007,6 @@ def scenario_economic_results(request, scen_id=None): scenario = get_object_or_404(Scenario, pk=scen_id) - # if scenario.project.user != request.user: - # return HttpResponseForbidden() - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied - try: kpi_cost_results_obj = KPICostsMatrixResults.objects.get( simulation=scenario.simulation @@ -1093,6 +1075,7 @@ def scenario_economic_results(request, scen_id=None): @login_required @json_view @require_http_methods(["GET"]) +@viewer_has_view_rights def scenario_visualize_timeseries(request, proj_id=None, scen_id=None): if scen_id is None: selected_scenario = get_selected_scenarios_in_cache(request, proj_id) @@ -1103,11 +1086,6 @@ def scenario_visualize_timeseries(request, proj_id=None, scen_id=None): for scen_id in selected_scenario: scenario = get_object_or_404(Scenario, pk=scen_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied simulations.append(scenario.simulation) results_json = report_item_render_to_json( @@ -1122,13 +1100,10 @@ def scenario_visualize_timeseries(request, proj_id=None, scen_id=None): ) +@login_required +@viewer_has_view_rights def scenario_visualize_stacked_timeseries(request, scen_id): scenario = get_object_or_404(Scenario, pk=scen_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied results_json = [] for energy_vector in scenario.energy_vectors: @@ -1151,6 +1126,7 @@ def scenario_visualize_stacked_timeseries(request, scen_id): # TODO exclude sink components +@viewer_has_view_rights def scenario_visualize_capacities(request, proj_id, scen_id=None): if scen_id is None: selected_scenario = get_selected_scenarios_in_cache(request, proj_id) @@ -1161,11 +1137,6 @@ def scenario_visualize_capacities(request, proj_id, scen_id=None): qs = Scenario.objects.filter(id__in=selected_scenario).order_by("name") for scenario in qs: - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied simulations.append(scenario.simulation) results_json = report_item_render_to_json( @@ -1180,6 +1151,7 @@ def scenario_visualize_capacities(request, proj_id, scen_id=None): ) +@viewer_has_view_rights def scenario_visualize_costs(request, proj_id, scen_id=None): if scen_id is None: selected_scenario = get_selected_scenarios_in_cache(request, proj_id) @@ -1190,11 +1162,6 @@ def scenario_visualize_costs(request, proj_id, scen_id=None): qs = Scenario.objects.filter(id__in=selected_scenario).order_by("name") for scenario in qs: - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied simulations.append(scenario.simulation) results_json = [] @@ -1216,13 +1183,11 @@ def scenario_visualize_costs(request, proj_id, scen_id=None): # TODO: Sector coupling must be refined (including transformer flows) +@login_required +@viewer_has_view_rights def scenario_visualize_sankey(request, scen_id, ts=None): scenario = get_object_or_404(Scenario, pk=scen_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied + if ts is not None: ts = int(ts) results_json = report_item_render_to_json( @@ -1243,15 +1208,10 @@ def scenario_visualize_sankey(request, scen_id, ts=None): @login_required @require_http_methods(["GET"]) +@viewer_has_view_rights def download_scalar_results(request, scen_id): scenario = get_object_or_404(Scenario, pk=scen_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied - try: kpi_scalar_results_obj = KPIScalarResults.objects.get( simulation=scenario.simulation @@ -1290,15 +1250,10 @@ def download_scalar_results(request, scen_id): @login_required @require_http_methods(["GET"]) +@viewer_has_view_rights def download_cost_results(request, scen_id): scenario = get_object_or_404(Scenario, pk=scen_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied - try: kpi_cost_results_obj = KPICostsMatrixResults.objects.get( simulation=scenario.simulation @@ -1336,15 +1291,10 @@ def download_cost_results(request, scen_id): @login_required @require_http_methods(["GET"]) +@viewer_has_view_rights def download_timeseries_results(request, scen_id): scenario = get_object_or_404(Scenario, pk=scen_id) - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied - try: assets_results_obj = AssetsResults.objects.get(simulation=scenario.simulation) assets_results_json = json.loads(assets_results_obj.assets_list) From fee5c9dcbb6f5b79d04ed2398f5b252111a8374d Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 27 May 2026 15:12:50 +0200 Subject: [PATCH 07/14] removed doubled viewer right check --- app/dashboard/views.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/dashboard/views.py b/app/dashboard/views.py index f458dbe0..4ff01509 100644 --- a/app/dashboard/views.py +++ b/app/dashboard/views.py @@ -212,12 +212,6 @@ def scenario_visualize_results( scenario = get_object_or_404(Scenario, id=scen_id) # TODO: change this when multi-scenario selection is allowed - if (scenario.project.user != request.user) and ( - scenario.project.viewers.filter(user__email=request.user.email).exists() - is False - ): - raise PermissionDenied - qs = FancyResults.objects.filter(simulation=scenario.simulation) if qs.exists() and scenario in user_scenarios: From 668ddc694937181b8420fefec36d0a8cdd4046a1 Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 27 May 2026 15:18:14 +0200 Subject: [PATCH 08/14] added new decorator if just the owner of project has permission --- app/projects/decorators.py | 26 ++++++++++++++++++++++++-- app/projects/views.py | 6 +++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/app/projects/decorators.py b/app/projects/decorators.py index 723ebba4..f445a82b 100644 --- a/app/projects/decorators.py +++ b/app/projects/decorators.py @@ -6,6 +6,28 @@ from projects.models import Project, Scenario +def user_is_owner(view_func): + @wraps(view_func) + def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): + if proj_id: + project = get_object_or_404(Project, pk=proj_id) + elif scen_id: + scenario = get_object_or_404(Scenario, pk=scen_id) + project = scenario.project + + if project.user != request.user: + raise PermissionDenied + # check for existing parameters to handle the different view parameters + if proj_id is not None and scen_id is not None: + return view_func(request, proj_id, scen_id, *args, **kwargs) + elif proj_id is not None: + return view_func(request, proj_id, *args, **kwargs) + elif scen_id is not None: + return view_func(request, scen_id, *args, **kwargs) + + return _wrapped_view + + def viewer_has_view_rights(view_func): @wraps(view_func) def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): @@ -14,7 +36,7 @@ def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): elif scen_id: scenario = get_object_or_404(Scenario, pk=scen_id) project = scenario.project - # oder ersetzen durch user.has_read_rights + if (project.user != request.user) and ( project.viewers.filter(user__email=request.user.email).exists() is False ): @@ -38,7 +60,7 @@ def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): elif scen_id: scenario = get_object_or_404(Scenario, pk=scen_id) project = scenario.project - # oder ersetzen durch user.has_edit_rights + if (project.user != request.user) and ( project.viewers.filter( user__email=request.user.email, share_rights="edit" diff --git a/app/projects/views.py b/app/projects/views.py index 59e5a66b..80079550 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -69,7 +69,11 @@ MaxEmissionConstraint, NZEConstraint, ) -from projects.decorators import viewer_has_view_rights, viewer_has_edit_rights +from projects.decorators import ( + user_is_owner, + viewer_has_view_rights, + viewer_has_edit_rights, +) from dashboard.models import FancyResults from .scenario_topology_helpers import ( handle_storage_unit_form_post, From 3142fc2e6aff5a8a38a601cb36e66e77f3e769a8 Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:13:46 +0200 Subject: [PATCH 09/14] added is owner decorator to project views --- app/projects/views.py | 85 ++++++++++--------------------------------- 1 file changed, 19 insertions(+), 66 deletions(-) diff --git a/app/projects/views.py b/app/projects/views.py index 80079550..17b663dc 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -131,16 +131,13 @@ def landing_default(request, version=1): @login_required @require_http_methods(["POST"]) +@user_is_owner def scenario_upload(request, proj_id): # read the scenario file to a dict scenario_data = request.FILES["file"].read() scenario_data = json.loads(scenario_data) - project = get_object_or_404(Project, id=proj_id) - if project.user != request.user: - raise PermissionDenied - answer = HttpResponseRedirect(reverse("project_search")) file_format_error = False # make a single scenario within a list @@ -219,16 +216,10 @@ def sponsor_feature(request): @login_required @json_view @require_http_methods(["GET"]) +@user_is_owner def project_members_list(request, proj_id): project = get_object_or_404(Project, pk=proj_id) - if project.user != request.user: - return JsonResponse( - {"status": "error", "message": "Project does not belong to you."}, - status=403, - content_type="application/json", - ) - viewers = project.viewers.values_list("email", flat=True) return JsonResponse( {"status": "success", "viewers": list(viewers)}, @@ -239,14 +230,11 @@ def project_members_list(request, proj_id): @login_required @require_http_methods(["POST"]) +@user_is_owner def project_share(request, proj_id): qs = request.POST - project = get_object_or_404(Project, id=proj_id) - if project.user != request.user: - raise PermissionDenied - form_item = ProjectShareForm(qs) if form_item.is_valid(): @@ -262,13 +250,11 @@ def project_share(request, proj_id): @login_required @json_view @require_http_methods(["POST"]) +@user_is_owner def project_revoke_access(request, proj_id=None): qs = request.POST - project = get_object_or_404(Project, id=proj_id) - if project.user != request.user: - raise PermissionDenied form_item = ProjectRevokeForm(qs, proj_id=proj_id) if form_item.is_valid(): success, message = project.revoke_access(**form_item.cleaned_data) @@ -283,13 +269,10 @@ def project_revoke_access(request, proj_id=None): @login_required @json_view @require_http_methods(["POST"]) +@user_is_owner def ajax_project_viewers_form(request): if request.headers.get("x-requested-with") == "XMLHttpRequest": proj_id = int(request.POST.get("proj_id")) - project = get_object_or_404(Project, id=proj_id) - - if project.user != request.user: - raise PermissionDenied form_item = ProjectRevokeForm(proj_id=proj_id) return render( @@ -427,12 +410,10 @@ def project_update(request, proj_id): @login_required @require_http_methods(["GET", "POST"]) +@user_is_owner def project_export(request, proj_id): project = get_object_or_404(Project, id=proj_id) - if project.user != request.user: - raise PermissionDenied - if request.method == "POST": bind_scenario_data = request.POST.get("bind_scenario_data", True) if bind_scenario_data == "True": @@ -508,12 +489,10 @@ def usecase_export(request, usecase_id): @login_required @require_http_methods(["POST"]) +@user_is_owner def project_delete(request, proj_id): project = get_object_or_404(Project, id=proj_id) - if project.user != request.user: - raise PermissionDenied - if request.method == "POST": project.delete() messages.success(request, "Project successfully deleted!") @@ -1255,11 +1234,10 @@ def scenario_view(request, scen_id, step_id): # TODO delete this useless code here @login_required @require_http_methods(["GET"]) +@user_is_owner def scenario_update(request, scen_id, step_id): """Scenario Update View. POST request only.""" scenario = get_object_or_404(Scenario, pk=scen_id) - if scenario.project.user != request.user: - raise PermissionDenied if request.POST: form = ScenarioUpdateForm(request.POST) if form.is_valid(): @@ -1277,13 +1255,11 @@ def scenario_update(request, scen_id, step_id): @login_required @require_http_methods(["GET"]) +@user_is_owner def scenario_duplicate(request, scen_id): """duplicates the selected scenario and all of its associated components (topology data included)""" scenario = get_object_or_404(Scenario, pk=scen_id) - if scenario.project.user != request.user: - raise PermissionDenied - # We need to iterate over all the objects related to this scenario and duplicate them # and associate them with the new scenario id. asset_list = Asset.objects.filter(scenario=scenario) @@ -1329,12 +1305,10 @@ def scenario_export(request, proj_id): @login_required @require_http_methods(["GET"]) +@user_is_owner def scenario_export_as_datapackage(request, scen_id, n_timestamps=None): scenario = get_object_or_404(Scenario, id=int(scen_id)) - if scenario.project.user != request.user: - raise PermissionDenied - with tempfile.TemporaryDirectory() as temp_dir: destination_path = Path(temp_dir) # write the content of the scenario into a temp directory @@ -1362,10 +1336,9 @@ def scenario_export_as_datapackage(request, scen_id, n_timestamps=None): @login_required @require_http_methods(["GET"]) +@user_is_owner def scenario_export_as_jsonified_datapackage(request, scen_id, n_timestamps=None): scenario = get_object_or_404(Scenario, id=int(scen_id)) - if scenario.project.user != request.user: - raise PermissionDenied json_dp = scenario.to_jsonified_datapackage(number=n_timestamps) response = JsonResponse(json_dp, status=200, content_type="application/json") @@ -1375,12 +1348,10 @@ def scenario_export_as_jsonified_datapackage(request, scen_id, n_timestamps=None @login_required @require_http_methods(["GET"]) +@user_is_owner def project_export_as_datapackage(request, proj_id, n_timestamps=None): project = get_object_or_404(Project, id=int(proj_id)) - if project.user != request.user: - raise PermissionDenied - with tempfile.TemporaryDirectory() as temp_dir: destination_path = Path(temp_dir) @@ -1409,13 +1380,9 @@ def project_export_as_datapackage(request, proj_id, n_timestamps=None): @login_required @require_http_methods(["POST"]) +@user_is_owner def scenario_delete(request, scen_id): scenario = get_object_or_404(Scenario, id=scen_id) - if scenario.project.user != request.user: - logger.warning( - f"Unauthorized user tried to delete project scenario with db id = {scen_id}." - ) - raise PermissionDenied if request.POST: scenario.delete() messages.success(request, "scenario successfully deleted!") @@ -1451,11 +1418,10 @@ def reset_scenario_changes(request, scen_id): @login_required @require_http_methods(["GET", "POST"]) +@user_is_owner def sensitivity_analysis_create(request, scen_id, sa_id=None, step_id=5): excuses_design_under_development(request) scenario = get_object_or_404(Scenario, id=scen_id) - if scenario.project.user != request.user: - raise PermissionDenied if request.method == "GET": if sa_id is not None: @@ -1894,6 +1860,7 @@ def asset_cops_create_or_update( @json_view @login_required @require_http_methods(["GET"]) +@user_is_owner def view_mvs_data_input(request, scen_id=0, testing=False): if scen_id == 0: return JsonResponse( @@ -1903,13 +1870,6 @@ def view_mvs_data_input(request, scen_id=0, testing=False): ) # Load scenario scenario = Scenario.objects.get(id=scen_id) - - if scenario.project.user != request.user: - logger.warning( - f"Unauthorized user tried to access scenario with db id = {scen_id}." - ) - raise PermissionDenied - try: data_clean = format_scenario_for_mvs(scenario, testing) except Exception as e: @@ -2137,19 +2097,12 @@ def fetch_sensitivity_analysis_results(request, sa_id): @login_required @require_http_methods(["GET"]) +@user_is_owner def simulation_cancel(request, scen_id): scenario = get_object_or_404(Scenario, id=scen_id) - if scenario.project.user == request.user: - qs = Simulation.objects.filter(scenario=scen_id) - if qs.exists(): - scenario.simulation.delete() - else: - messages.error( - request, - _( - "You do not have the permission to reset a simulation on a project shared with you" - ), - ) + qs = Simulation.objects.filter(scenario=scen_id) + if qs.exists(): + scenario.simulation.delete() return HttpResponseRedirect( reverse("scenario_review", args=[scenario.project.id, scen_id]) From 5e7e06dfe5ebd3a6a25137ae5fa7147a5e5d2dd1 Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:21:21 +0200 Subject: [PATCH 10/14] renamed decorators --- app/dashboard/views.py | 24 ++++++++++++------------ app/projects/decorators.py | 4 ++-- app/projects/views.py | 20 ++++++++++---------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/app/dashboard/views.py b/app/dashboard/views.py index 4ff01509..2fcf9e7e 100644 --- a/app/dashboard/views.py +++ b/app/dashboard/views.py @@ -36,7 +36,7 @@ get_selected_scenarios_in_cache, ) -from projects.decorators import viewer_has_edit_rights, viewer_has_view_rights +from projects.decorators import user_has_edit_rights, user_has_read_rights from projects.forms import BusForm, AssetCreateForm, StorageForm from projects.constants import COMPARE_VIEW, STEP_LIST, MAX_STEP @@ -73,7 +73,7 @@ @login_required @json_view @require_http_methods(["GET"]) -@viewer_has_view_rights +@user_has_read_rights def scenario_available_results(request, scen_id): scenario = get_object_or_404(Scenario, pk=scen_id) @@ -260,7 +260,7 @@ def scenario_visualize_results( @login_required @require_http_methods(["POST", "GET"]) -@viewer_has_view_rights +@user_has_read_rights def project_compare_results(request, proj_id, step_id=5, max_step=MAX_STEP): request.session[COMPARE_VIEW] = True user_projects = fetch_user_projects(request.user) @@ -985,7 +985,7 @@ def view_asset_parameters(request, scen_id, asset_type_name, asset_uuid): @login_required @json_view @require_http_methods(["GET"]) -@viewer_has_view_rights +@user_has_read_rights def scenario_economic_results(request, scen_id=None): """ This view gathers all simulation specific cost matrix KPI results @@ -1069,7 +1069,7 @@ def scenario_economic_results(request, scen_id=None): @login_required @json_view @require_http_methods(["GET"]) -@viewer_has_view_rights +@user_has_read_rights def scenario_visualize_timeseries(request, proj_id=None, scen_id=None): if scen_id is None: selected_scenario = get_selected_scenarios_in_cache(request, proj_id) @@ -1095,7 +1095,7 @@ def scenario_visualize_timeseries(request, proj_id=None, scen_id=None): @login_required -@viewer_has_view_rights +@user_has_read_rights def scenario_visualize_stacked_timeseries(request, scen_id): scenario = get_object_or_404(Scenario, pk=scen_id) @@ -1120,7 +1120,7 @@ def scenario_visualize_stacked_timeseries(request, scen_id): # TODO exclude sink components -@viewer_has_view_rights +@user_has_read_rights def scenario_visualize_capacities(request, proj_id, scen_id=None): if scen_id is None: selected_scenario = get_selected_scenarios_in_cache(request, proj_id) @@ -1145,7 +1145,7 @@ def scenario_visualize_capacities(request, proj_id, scen_id=None): ) -@viewer_has_view_rights +@user_has_read_rights def scenario_visualize_costs(request, proj_id, scen_id=None): if scen_id is None: selected_scenario = get_selected_scenarios_in_cache(request, proj_id) @@ -1178,7 +1178,7 @@ def scenario_visualize_costs(request, proj_id, scen_id=None): # TODO: Sector coupling must be refined (including transformer flows) @login_required -@viewer_has_view_rights +@user_has_read_rights def scenario_visualize_sankey(request, scen_id, ts=None): scenario = get_object_or_404(Scenario, pk=scen_id) @@ -1202,7 +1202,7 @@ def scenario_visualize_sankey(request, scen_id, ts=None): @login_required @require_http_methods(["GET"]) -@viewer_has_view_rights +@user_has_read_rights def download_scalar_results(request, scen_id): scenario = get_object_or_404(Scenario, pk=scen_id) @@ -1244,7 +1244,7 @@ def download_scalar_results(request, scen_id): @login_required @require_http_methods(["GET"]) -@viewer_has_view_rights +@user_has_read_rights def download_cost_results(request, scen_id): scenario = get_object_or_404(Scenario, pk=scen_id) @@ -1285,7 +1285,7 @@ def download_cost_results(request, scen_id): @login_required @require_http_methods(["GET"]) -@viewer_has_view_rights +@user_has_read_rights def download_timeseries_results(request, scen_id): scenario = get_object_or_404(Scenario, pk=scen_id) diff --git a/app/projects/decorators.py b/app/projects/decorators.py index f445a82b..8f836d05 100644 --- a/app/projects/decorators.py +++ b/app/projects/decorators.py @@ -28,7 +28,7 @@ def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): return _wrapped_view -def viewer_has_view_rights(view_func): +def user_has_read_rights(view_func): @wraps(view_func) def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): if proj_id: @@ -52,7 +52,7 @@ def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): return _wrapped_view -def viewer_has_edit_rights(view_func): +def user_has_edit_rights(view_func): @wraps(view_func) def _wrapped_view(request, proj_id=None, scen_id=None, *args, **kwargs): if proj_id: diff --git a/app/projects/views.py b/app/projects/views.py index 17b663dc..c1853cd0 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -71,8 +71,8 @@ ) from projects.decorators import ( user_is_owner, - viewer_has_view_rights, - viewer_has_edit_rights, + user_has_read_rights, + user_has_edit_rights, ) from dashboard.models import FancyResults from .scenario_topology_helpers import ( @@ -284,7 +284,7 @@ def ajax_project_viewers_form(request): @login_required @require_http_methods(["GET"]) -@viewer_has_view_rights +@user_has_read_rights def project_detail(request, proj_id): project = get_object_or_404(Project, pk=proj_id) logger.info(f"Populating project and economic details in forms.") @@ -329,7 +329,7 @@ def project_create(request): @login_required @require_http_methods(["GET", "POST"]) -@viewer_has_edit_rights +@user_has_edit_rights def project_update(request, proj_id): project = get_object_or_404(Project, id=proj_id) @@ -568,7 +568,7 @@ def project_search(request, proj_id=None, scen_id=None): @login_required @require_http_methods(["POST"]) -@viewer_has_edit_rights +@user_has_edit_rights def project_duplicate(request, proj_id): """Duplicates the selected project along with its associated scenarios""" project = get_object_or_404(Project, pk=proj_id) @@ -737,7 +737,7 @@ def scenario_select_project(request, step_id=0, max_step=1): @login_required @require_http_methods(["GET", "POST"]) -@viewer_has_view_rights +@user_has_read_rights def scenario_create_parameters(request, proj_id, scen_id=None, step_id=1, max_step=2): project = get_object_or_404(Project, pk=proj_id) # all projects which the user is able to select (the one the user created) @@ -967,7 +967,7 @@ def scenario_create_topology(request, proj_id, scen_id, step_id=2, max_step=3): @login_required @require_http_methods(["GET", "POST"]) -@viewer_has_view_rights +@user_has_read_rights def scenario_create_constraints(request, proj_id, scen_id, step_id=3, max_step=4): constraints_labels = { "minimal_degree_of_autonomy": _("Minimal degree of autonomy"), @@ -1079,7 +1079,7 @@ def scenario_create_constraints(request, proj_id, scen_id, step_id=3, max_step=4 @login_required @require_http_methods(["GET", "POST"]) -@viewer_has_view_rights +@user_has_read_rights def scenario_review(request, proj_id, scen_id, step_id=4, max_step=MAX_STEP): scenario = get_object_or_404(Scenario, pk=scen_id) @@ -1224,7 +1224,7 @@ def scenario_steps(request, proj_id, step_id=None, scen_id=None): # TODO delete this useless code here @login_required @require_http_methods(["GET"]) -@viewer_has_view_rights +@user_has_read_rights def scenario_view(request, scen_id, step_id): """Scenario View. GET request only.""" scenario = get_object_or_404(Scenario, pk=scen_id) @@ -1398,7 +1398,7 @@ def scenario_delete(request, scen_id): @login_required @require_http_methods(["POST"]) -@viewer_has_edit_rights +@user_has_edit_rights def reset_scenario_changes(request, scen_id): scenario = get_object_or_404(Scenario, id=scen_id) From 7b3507eb16635afba8710e198655ca9536db1925 Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:23:48 +0200 Subject: [PATCH 11/14] added another shorter check --- app/projects/views.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app/projects/views.py b/app/projects/views.py index c1853cd0..093bbe00 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -1066,12 +1066,7 @@ def scenario_create_constraints(request, proj_id, scen_id, step_id=3, max_step=4 if constraint_type == "net_zero_energy": constraint_instance.value = constraint_instance.activated - if (scenario.project.user == request.user) or ( - scenario.project.viewers.filter( - user__email=request.user.email, share_rights="edit" - ).exists() - is True - ): + if request.user.has_edit_rights(scenario.project): constraint_instance.save() return HttpResponseRedirect(reverse("scenario_review", args=[proj_id, scen_id])) From 989f0e639245fe1e23881ecc625566b03e1c0b91 Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:34:13 +0200 Subject: [PATCH 12/14] changed permission for seeing t and updating imeseries graph to edit rights --- app/projects/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/projects/views.py b/app/projects/views.py index 093bbe00..efbad8bd 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -1561,7 +1561,10 @@ def get_timeseries(request, ts_id=None): if request.method == "GET": if ts_id is not None: ts = Timeseries.objects.get(id=ts_id) - if ts.user != request.user and ts.open_source is False: + if ( + request.user.has_edit_rights(ts.scenario.project) + and ts.open_source is False + ): raise PermissionDenied return JsonResponse({"values": ts.get_values}) From 1280f31e3ea511c608655bc33acd1830afb6df6c Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:35:07 +0200 Subject: [PATCH 13/14] changed permission for duplicating a shared scenario in own project overview to edit rights --- app/projects/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/projects/views.py b/app/projects/views.py index efbad8bd..9a0efb6e 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -1250,7 +1250,7 @@ def scenario_update(request, scen_id, step_id): @login_required @require_http_methods(["GET"]) -@user_is_owner +@user_has_edit_rights def scenario_duplicate(request, scen_id): """duplicates the selected scenario and all of its associated components (topology data included)""" scenario = get_object_or_404(Scenario, pk=scen_id) From 2e81d0d420e72a4433aa3f100455c7e48efcc441 Mon Sep 17 00:00:00 2001 From: josihoppe <116898820+josihoppe@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:37:05 +0200 Subject: [PATCH 14/14] changed permission for deleting project in own project overview to read rights --- app/projects/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/projects/views.py b/app/projects/views.py index 9a0efb6e..da94eac9 100644 --- a/app/projects/views.py +++ b/app/projects/views.py @@ -489,7 +489,7 @@ def usecase_export(request, usecase_id): @login_required @require_http_methods(["POST"]) -@user_is_owner +@user_has_read_rights def project_delete(request, proj_id): project = get_object_or_404(Project, id=proj_id)