diff --git a/argus/backend/controller/api.py b/argus/backend/controller/api.py index eecbac2c..299781c2 100644 --- a/argus/backend/controller/api.py +++ b/argus/backend/controller/api.py @@ -14,6 +14,7 @@ from argus.backend.controller.testrun_api import bp as testrun_bp from argus.backend.controller.team import bp as team_bp from argus.backend.controller.view_api import bp as view_bp +from argus.backend.controller.planner_api import bp as planner_bp from argus.backend.service.argus_service import ArgusService, ScheduleUpdateRequest from argus.backend.service.results_service import ResultsService from argus.backend.service.user import UserService, api_login_required @@ -27,6 +28,7 @@ bp.register_blueprint(testrun_bp) bp.register_blueprint(team_bp) bp.register_blueprint(view_bp) +bp.register_blueprint(planner_bp) bp.register_error_handler(Exception, handle_api_exception) LOGGER = logging.getLogger(__name__) @@ -189,10 +191,12 @@ def release_schedules_assignee_update(): @api_login_required def group_assignees(): release_id = request.args.get("releaseId") + version = request.args.get("version") + plan_id = request.args.get("planId") if not release_id: raise Exception("Missing releaseId") service = ArgusService() - group_assignees_list = service.get_groups_assignees(release_id) + group_assignees_list = service.get_groups_assignees(release_id, version, plan_id) return jsonify({ "status": "ok", @@ -204,10 +208,12 @@ def group_assignees(): @api_login_required def tests_assignees(): group_id = request.args.get("groupId") + version = request.args.get("version") + plan_id = request.args.get("planId") if not group_id: raise Exception("Missing groupId") service = ArgusService() - tests_assignees_list = service.get_tests_assignees(group_id) + tests_assignees_list = service.get_tests_assignees(group_id, version, plan_id) return jsonify({ "status": "ok", diff --git a/argus/backend/controller/main.py b/argus/backend/controller/main.py index f153ab61..a396f71a 100644 --- a/argus/backend/controller/main.py +++ b/argus/backend/controller/main.py @@ -10,6 +10,7 @@ from argus.backend.service.argus_service import ArgusService from argus.backend.models.web import WebFileStorage from argus.backend.service.testrun import TestRunService +from argus.backend.service.planner_service import PlanningService from argus.backend.service.user import UserService, login_required from argus.backend.service.views import UserViewService @@ -121,6 +122,14 @@ def release_scheduler(name: str): return render_template("release_schedule.html.j2", release_name=name, data=data_json) +@bp.route("/release//planner") +@login_required +def release_planner(name: str): + service = PlanningService() + planner_data = service.release_planner(name) + return render_template("release_planner.html.j2", release_name=planner_data["release"]["name"], planner_data=planner_data) + + @bp.route("/release//duty") @login_required def duty_planner(name: str): diff --git a/argus/backend/controller/planner_api.py b/argus/backend/controller/planner_api.py new file mode 100644 index 00000000..b5bc7201 --- /dev/null +++ b/argus/backend/controller/planner_api.py @@ -0,0 +1,178 @@ +import logging +from uuid import UUID +from flask import ( + Blueprint, + request +) +from argus.backend.error_handlers import handle_api_exception +from argus.backend.service.planner_service import CopyPlanPayload, PlanningService, TempPlanPayload +from argus.backend.service.test_lookup import TestLookup +from argus.backend.service.user import api_login_required +from argus.backend.util.common import get_payload + +bp = Blueprint('planning_api', __name__, url_prefix='/planning') +LOGGER = logging.getLogger(__name__) +bp.register_error_handler(Exception, handle_api_exception) + + +@bp.route("/", methods=["GET"]) +@api_login_required +def version(): + + result = PlanningService().version() + + return { + "status": "ok", + "response": result + } + + +@bp.route("/plan//copy/check", methods=["GET"]) +@api_login_required +def is_plan_eligible_for_copy(plan_id: str): + release_id = request.args.get("releaseId") + if not release_id: + raise Exception("Missing release id.") + + result = PlanningService().check_plan_copy_eligibility(plan_id=UUID(plan_id), target_release_id=UUID(release_id)) + + return { + "status": "ok", + "response": result + } + + +@bp.route("/release//gridview", methods=["GET"]) +@api_login_required +def grid_view_for_release(release_id: str): + + result = PlanningService().get_gridview_for_release(release_id=UUID(release_id)) + + return { + "status": "ok", + "response": result + } + + +@bp.route("/search", methods=["GET"]) +@api_login_required +def search_tests(): + query = request.args.get("query") + release_id = request.args.get('releaseId') + service = TestLookup + if query: + res = service.test_lookup(query, release_id=release_id) + else: + res = [] + return { + "status": "ok", + "response": { + "hits": res, + "total": len(res) + } + } + +@bp.route("/group//explode", methods=["GET"]) +@api_login_required +def explode_group(group_id: str): + service = TestLookup + res = service.explode_group(group_id=group_id) + return { + "status": "ok", + "response": res + } + + +@bp.route("/plan//get", methods=["GET"]) +@api_login_required +def get_plan(plan_id: str): + result = PlanningService().get_plan(plan_id) + + return { + "status": "ok", + "response": result + } + + +@bp.route("/release//all", methods=["GET"]) +@api_login_required +def get_plans_for_release(release_id: str): + result = PlanningService().get_plans_for_release(release_id) + + return { + "status": "ok", + "response": result + } + + +@bp.route("/plan/create", methods=["POST"]) +@api_login_required +def create_plan(): + payload = get_payload(request) + result = PlanningService().create_plan(payload) + + return { + "status": "ok", + "response": result + } + + +@bp.route("/plan/update", methods=["POST"]) +@api_login_required +def update_plan(): + payload = get_payload(request) + result = PlanningService().update_plan(payload) + + return { + "status": "ok", + "response": result + } + +@bp.route("/plan/copy", methods=["POST"]) +@api_login_required +def copy_plan(): + payload = get_payload(request) + payload["plan"] = TempPlanPayload(**payload["plan"]) + result = PlanningService().copy_plan(CopyPlanPayload(**payload)) + + return { + "status": "ok", + "response": result + } + + + +@bp.route("/plan//delete", methods=["DELETE"]) +@api_login_required +def delete_plan(plan_id: str): + result = PlanningService().delete_plan(plan_id) + + return { + "status": "ok", + "response": result + } + + +@bp.route("/plan//owner/set", methods=["POST"]) +@api_login_required +def change_plan_owner(plan_id: str): + payload = get_payload(request) + result = PlanningService().change_plan_owner(plan_id=plan_id, new_owner=payload["newOwner"]) + + return { + "status": "ok", + "response": result + } + + +@bp.route("/plan//resolve_entities", methods=["GET"]) +@api_login_required +def resolve_plan_entities(plan_id: str): + + service = PlanningService() + result = service.resolve_plan(plan_id) + + return { + "status": "ok", + "response": result, + } diff --git a/argus/backend/models/plan.py b/argus/backend/models/plan.py new file mode 100644 index 00000000..c54f5f45 --- /dev/null +++ b/argus/backend/models/plan.py @@ -0,0 +1,24 @@ +import datetime +from cassandra.cqlengine import columns +from cassandra.cqlengine.models import Model +from cassandra.cqlengine.usertype import UserType +from cassandra.util import uuid_from_time + + +class ArgusReleasePlan(Model): + id = columns.TimeUUID(partition_key=True, default=lambda: uuid_from_time(datetime.datetime.now(tz=datetime.UTC))) + name = columns.Text(required=True) + completed = columns.Boolean(default=lambda: False) + description = columns.Text() + owner = columns.UUID(required=True) + participants = columns.List(value_type=columns.UUID) + target_version = columns.Ascii() + assignee_mapping = columns.Map(key_type=columns.UUID, value_type=columns.UUID) + release_id = columns.UUID(index=True) + tests = columns.List(value_type=columns.UUID) + groups = columns.List(value_type=columns.UUID) + view_id = columns.UUID(index=True) + created_from = columns.UUID(index=True) + creation_time = columns.DateTime(default=lambda: datetime.datetime.now(tz=datetime.UTC)) + last_updated = columns.DateTime(default=lambda: datetime.datetime.now(tz=datetime.UTC)) + ends_at = columns.DateTime() diff --git a/argus/backend/models/web.py b/argus/backend/models/web.py index a2c152e8..48bdd3ad 100644 --- a/argus/backend/models/web.py +++ b/argus/backend/models/web.py @@ -6,6 +6,7 @@ from cassandra.cqlengine import columns from cassandra.util import uuid_from_time, unix_time_from_uuid1 # pylint: disable=no-name-in-module +from argus.backend.models.plan import ArgusReleasePlan from argus.backend.models.result import ArgusGenericResultMetadata, ArgusGenericResultData, ArgusBestResultData @@ -154,6 +155,7 @@ class ArgusUserView(Model): display_name = columns.Text() description = columns.Text() user_id = columns.UUID(required=True, index=True) + plan_id = columns.UUID(index=True) tests = columns.List(value_type=columns.UUID, default=lambda: []) release_ids = columns.List(value_type=columns.UUID, default=lambda: []) group_ids = columns.List(value_type=columns.UUID, default=lambda: []) @@ -382,6 +384,7 @@ class WebFileStorage(Model): ArgusGenericResultMetadata, ArgusGenericResultData, ArgusBestResultData, + ArgusReleasePlan, ] USED_TYPES: list[UserType] = [ diff --git a/argus/backend/plugins/core.py b/argus/backend/plugins/core.py index 1546ac47..9a83e728 100644 --- a/argus/backend/plugins/core.py +++ b/argus/backend/plugins/core.py @@ -9,6 +9,7 @@ from cassandra.cqlengine.usertype import UserType from flask import Blueprint from argus.backend.db import ScyllaCluster +from argus.backend.models.plan import ArgusReleasePlan from argus.backend.models.web import ( ArgusTest, ArgusGroup, @@ -65,13 +66,34 @@ def assign_categories(self): except ArgusTest.DoesNotExist: LOGGER.warning("Test entity missing for key \"%s\", run won't be visible until this is corrected", key) + def get_assignment(self, version: str | None = None) -> UUID | None: + associated_test: ArgusTest = ArgusTest.get(build_system_id=self.build_id) + associated_release: ArgusRelease = ArgusRelease.get(id=associated_test.release_id) + if associated_release.perpetual: + return self._legacy_get_scheduled_assignee(associated_test=associated_test, associated_release=associated_release) + + plans: list[ArgusReleasePlan] = list(ArgusReleasePlan.filter(release_id=associated_release.id)) + + if version: + plans = [plan for plan in plans if plan.target_version == version] + + for plan in plans: + if associated_test.group_id in plan.groups: + return plan.assignee_mapping.get(associated_test.group_id, plan.owner) + if associated_test.id in plan.tests: + return plan.assignee_mapping.get(associated_test.id, plan.owner) + + return None + + def get_scheduled_assignee(self) -> UUID: + return self.get_assignment() + + def _legacy_get_scheduled_assignee(self, associated_test: ArgusTest, associated_release: ArgusRelease) -> UUID: """ Iterate over all schedules (groups and tests) and return first available assignee """ - associated_test = ArgusTest.get(build_system_id=self.build_id) associated_group = ArgusGroup.get(id=associated_test.group_id) - associated_release = ArgusRelease.get(id=associated_test.release_id) scheduled_groups = ArgusScheduleGroup.filter( release_id=associated_release.id, diff --git a/argus/backend/plugins/driver_matrix_tests/model.py b/argus/backend/plugins/driver_matrix_tests/model.py index a0d1f029..c882f0f8 100644 --- a/argus/backend/plugins/driver_matrix_tests/model.py +++ b/argus/backend/plugins/driver_matrix_tests/model.py @@ -412,6 +412,9 @@ def get_events(self) -> list: def submit_product_version(self, version: str): self.scylla_version = version + new_assignee = self.get_assignment(version) + if new_assignee: + self.assignee = new_assignee def finish_run(self, payload: dict = None): self.end_time = datetime.utcnow() diff --git a/argus/backend/plugins/generic/model.py b/argus/backend/plugins/generic/model.py index 44bac604..7c6702b4 100644 --- a/argus/backend/plugins/generic/model.py +++ b/argus/backend/plugins/generic/model.py @@ -41,6 +41,9 @@ def submit_product_version(self, version: str): pattern = re.compile(r"((?P[\w.~]+)-(?P(0\.)?(?P[0-9]{8,8})\.(?P\w+).*))") if match := pattern.search(version): self.scylla_version = match.group("short") + new_assignee = self.get_assignment(match.group("short")) + if new_assignee: + self.assignee = new_assignee self.set_full_version(version) @classmethod diff --git a/argus/backend/plugins/sct/testrun.py b/argus/backend/plugins/sct/testrun.py index 0f7db773..89b75b98 100644 --- a/argus/backend/plugins/sct/testrun.py +++ b/argus/backend/plugins/sct/testrun.py @@ -216,6 +216,9 @@ def get_events(self) -> list[EventsBySeverity]: def submit_product_version(self, version: str): self.scylla_version = version + new_assignee = self.get_assignment(version) + if new_assignee: + self.assignee = new_assignee def finish_run(self, payload: dict = None): self.end_time = datetime.utcnow() diff --git a/argus/backend/plugins/sirenada/model.py b/argus/backend/plugins/sirenada/model.py index 63170b6d..16744047 100644 --- a/argus/backend/plugins/sirenada/model.py +++ b/argus/backend/plugins/sirenada/model.py @@ -61,6 +61,9 @@ def get_distinct_product_versions(cls, release: ArgusRelease, cluster: ScyllaClu def submit_product_version(self, version: str): self.scylla_version = version + new_assignee = self.get_assignment(version) + if new_assignee: + self.assignee = new_assignee def submit_logs(self, logs: dict[str, str]): raise SirenadaPluginException("Log submission is not supported for Sirenada") diff --git a/argus/backend/service/argus_service.py b/argus/backend/service/argus_service.py index fd9cfcbd..b17c8110 100644 --- a/argus/backend/service/argus_service.py +++ b/argus/backend/service/argus_service.py @@ -26,6 +26,7 @@ User, ) from argus.backend.events.event_processors import EVENT_PROCESSORS +from argus.backend.service.planner_service import PlanningService from argus.backend.service.testrun import TestRunService from argus.backend.util.common import chunk @@ -577,9 +578,11 @@ def _batch_get_schedules_from_ids(self, release_id: UUID, schedule_ids: list[UUI schedules.extend(ArgusSchedule.filter(release_id=release_id, id__in=next_slice).all()) return schedules - def get_groups_assignees(self, release_id: UUID | str): + def get_groups_assignees(self, release_id: UUID | str, version: str = None, plan_id: UUID = None): release_id = UUID(release_id) if isinstance(release_id, str) else release_id release = ArgusRelease.get(id=release_id) + if not release.perpetual: + return PlanningService().get_assignments_for_groups(release_id, version, plan_id) groups = ArgusGroup.filter(release_id=release_id).all() group_ids = [group.id for group in groups if group.enabled] @@ -613,11 +616,14 @@ def get_groups_assignees(self, release_id: UUID | str): return response - def get_tests_assignees(self, group_id: UUID | str): + def get_tests_assignees(self, group_id: UUID | str, version: str = None, plan_id: UUID = None): group_id = UUID(group_id) if isinstance(group_id, str) else group_id group = ArgusGroup.get(id=group_id) release = ArgusRelease.get(id=group.release_id) + if not release.perpetual: + return PlanningService().get_assignments_for_tests(group_id, version, plan_id) + tests = ArgusTest.filter(group_id=group_id).all() test_ids = [test.id for test in tests if test.enabled] diff --git a/argus/backend/service/planner_service.py b/argus/backend/service/planner_service.py new file mode 100644 index 00000000..fe6970e7 --- /dev/null +++ b/argus/backend/service/planner_service.py @@ -0,0 +1,440 @@ + +import logging +import datetime +import json +from collections import defaultdict +from copy import deepcopy +from dataclasses import dataclass +from functools import reduce +from typing import Any, Optional +from uuid import UUID +from slugify import slugify + +from argus.backend.models.plan import ArgusReleasePlan +from argus.backend.models.web import ArgusGroup, ArgusRelease, ArgusTest, ArgusUserView, User +from argus.backend.service.test_lookup import TestLookup +from argus.backend.service.views import UserViewService + + +LOGGER = logging.getLogger(__name__) + +@dataclass(frozen=True, init=True, repr=True, kw_only=True) +class CreatePlanPayload: + name: str + description: str + owner: str + participants: list[str] + target_version: str | None + release_id: str + tests: list[str] + groups: list[str] + assignments: dict[str, str] + created_from: Optional[str] = None + + +@dataclass(frozen=True, init=True, repr=True, kw_only=True) +class TempPlanPayload: + id: str + name: str + completed: bool + description: str + owner: str + participants: list[str] + target_version: str + assignee_mapping: dict[str, str] + assignments: dict[str, str] = None + release_id: str + tests: list[str] + groups: list[str] + view_id: str + creation_time: str + last_updated: str + ends_at: str + created_from: Optional[str] + +@dataclass(frozen=True, init=True, repr=True, kw_only=True) +class CopyPlanPayload: + plan: TempPlanPayload + keepParticipants: bool + replacements: dict[str, str] + targetReleaseId: str + targetReleaseName: str + + +class PlannerServiceException(Exception): + pass + + +class PlanningService: + + VIEW_WIDGET_SETTINGS = [ + { + "position": 1, + "type": "githubIssues", + "settings": { + "submitDisabled": True, + "aggregateByIssue": True + } + }, + { + "position": 2, + "type": "releaseStats", + "settings": { + "horizontal": False, + "displayExtendedStats": True, + "hiddenStatuses": ["not_run", "not_planned"] + } + }, + { + "position": 3, + "type": "testDashboard", + "settings": { + "targetVersion": True, + "versionsIncludeNoVersion": False, + "productVersion": None + } + } + ] + + def version(self): + return "v1" + + def create_plan(self, payload: dict[str, Any]) -> ArgusReleasePlan: + plan_request = CreatePlanPayload(**payload) + + try: + existing = ArgusReleasePlan.filter(name=plan_request.name, target_version=plan_request.target_version).allow_filtering().get() + if existing: + raise PlannerServiceException(f"Found existing plan {existing.name} ({existing.target_version}) with the same name and version", existing, plan_request) + except ArgusReleasePlan.DoesNotExist: + pass + + plan = ArgusReleasePlan() + plan.name = plan_request.name + plan.description = plan_request.description + plan.owner = UUID(plan_request.owner) + plan.target_version = plan_request.target_version + plan.release_id = UUID(plan_request.release_id) + plan.participants = plan_request.participants + plan.assignee_mapping = { UUID(entity_id): UUID(user_id) for entity_id, user_id in plan_request.assignments.items() } + plan.groups = plan_request.groups + plan.tests = plan_request.tests + if plan_request.created_from: + plan.created_from = plan_request.created_from + view = self.create_view_for_plan(plan) + plan.view_id = view.id + + plan.save() + + return plan + + def update_plan(self, payload: dict[str, Any]) -> bool: + plan_request = TempPlanPayload(**payload) + + try: + existing = ArgusReleasePlan.filter(name=plan_request.name, target_version=plan_request.target_version).allow_filtering().get() + if existing and existing.id != UUID(plan_request.id): + raise PlannerServiceException(f"Found existing plan {existing.name} ({existing.target_version}) with the same name and version", existing, plan_request) + except ArgusReleasePlan.DoesNotExist: + pass + + plan: ArgusReleasePlan = ArgusReleasePlan.get(id=plan_request.id) + plan.owner = plan_request.owner + plan.participants = plan_request.participants + plan.assignee_mapping = plan_request.assignee_mapping + plan.tests = plan_request.tests + plan.groups = plan_request.groups + plan.name = plan_request.name + plan.target_version = plan_request.target_version + plan.description = plan_request.description + plan.last_updated = datetime.datetime.now(tz=datetime.UTC) + plan.save() + + old_view: ArgusUserView = ArgusUserView.get(id=plan.view_id) + old_view.delete() + + view = self.create_view_for_plan(plan) + plan.view_id = view.id + plan.save() + + return True + + def create_view_for_plan(self, plan: ArgusReleasePlan) -> ArgusUserView: + service = UserViewService() + release: ArgusRelease = ArgusRelease.get(id=plan.release_id) + items = [f"test:{tid}" for tid in plan.tests] + items = [*items, *[f"group:{gid}" for gid in plan.groups]] + version_str = f" ({plan.target_version}) " if plan.target_version else "" + view_name = f"{release.name} {version_str}- {plan.name}" + settings = deepcopy(self.VIEW_WIDGET_SETTINGS) + if plan.target_version: + settings[2]["settings"]["productVersion"] = plan.target_version + else: + settings[2]["settings"]["targetVersion"] = False + view = service.create_view( + name=slugify(view_name), + display_name=view_name, + description=f"{plan.target_version or ''} Automatic view for the release plan \"{plan.name}\". {plan.description}", + items=items, + plan_id=plan.id, + widget_settings=json.dumps(settings), + ) + + view.save() + service.refresh_stale_view(view) + return view + + def change_plan_owner(self, plan_id: UUID | str, new_owner: UUID | str) -> bool: + user: User = User.get(id=new_owner) + plan: ArgusReleasePlan = ArgusReleasePlan.get(id=plan_id) + + plan.owner = user.id + plan.last_updated = datetime.datetime.now(tz=datetime.UTC) + + plan.save() + return True + + def get_plan(self, plan_id: str | UUID) -> ArgusReleasePlan: + return ArgusReleasePlan.get(id=plan_id) + + def get_gridview_for_release(self, release_id: str | UUID) -> dict[str, dict]: + release = ArgusRelease.get(id=release_id) + release = TestLookup.index_mapper(release, "release") + groups: list[ArgusGroup] = list(ArgusGroup.filter(release_id=release_id).all()) + tests: list[ArgusTest] = list(ArgusTest.filter(release_id=release_id).all()) + + groups = { str(g.id): TestLookup.index_mapper(g, "group") for g in groups if g.enabled } + + tests_by_group = reduce(lambda acc, test: acc[str(test.group_id)].append(test) or acc, tests, defaultdict(list)) + + res = { + "tests": { str(t.id): TestLookup.index_mapper(t) for t in tests if t.enabled and groups[str(t.group_id)]["enabled"] }, + "groups": groups, + "testByGroup": tests_by_group + } + + for group in res["groups"].values(): + group["release"] = release["name"] + + for test in res["tests"].values(): + g = res["groups"][str(test["group_id"])] + test["group"] = g["pretty_name"] or g["name"] + test["release"] = release["name"] + + return res + + def copy_plan(self, payload: CopyPlanPayload) -> ArgusReleasePlan: + + + try: + existing = ArgusReleasePlan.filter(name=payload.plan.name, target_version=payload.plan.target_version).allow_filtering().get() + if existing: + raise PlannerServiceException(f"Found existing plan {existing.name} ({existing.target_version}) with the same name and version", existing, payload) + except ArgusReleasePlan.DoesNotExist: + pass + + + original_plan: ArgusReleasePlan = ArgusReleasePlan.get(id=payload.plan.id) + target_release: ArgusRelease = ArgusRelease.get(id=payload.targetReleaseId) + original_release: ArgusRelease = ArgusRelease.get(id=original_plan.release_id) + + original_tests: list[ArgusTest] = ArgusTest.filter(id__in=original_plan.tests).all() + original_groups: list[ArgusGroup] = ArgusGroup.filter(id__in=original_plan.groups).all() + target_tests: list[ArgusTest] = ArgusTest.filter(release_id=target_release.id).all() + target_groups: list[ArgusGroup] = ArgusGroup.filter(release_id=target_release.id).all() + + tests_by_build_id = { t.build_system_id: t for t in target_tests } + groups_by_build_id = { g.build_system_id: g for g in target_groups } + + new_tests = [] + new_groups = [] + new_assignee_mapping = {} + + for test in original_tests: + original_assignee = original_plan.assignee_mapping.get(test.id) + new_build_id = test.build_system_id.replace(original_release.name, target_release.name, 1) + new_test = tests_by_build_id.get(new_build_id) + new_test_id = new_test.id if new_test else payload.replacements.get(test.id) + if new_test_id: + new_tests.append(new_test_id) + if original_assignee and payload.keepParticipants: + new_assignee_mapping[new_test_id] = original_assignee + + for group in original_groups: + original_assignee = original_plan.assignee_mapping.get(group.id) + new_build_id = group.build_system_id.replace(original_release.name, target_release.name, 1) + new_group = groups_by_build_id.get(new_build_id) + new_group_id = new_group.id if new_group else payload.replacements.get(group.id) + if new_group_id: + new_groups.append(new_group_id) + if original_assignee and payload.keepParticipants: + new_assignee_mapping[new_group_id] = original_assignee + + new_plan = ArgusReleasePlan() + new_plan.release_id = target_release.id + new_plan.owner = payload.plan.owner + new_plan.name = payload.plan.name + new_plan.description = payload.plan.description + if payload.keepParticipants: + new_plan.participants = payload.plan.participants + new_plan.assignee_mapping = new_assignee_mapping + new_plan.tests = new_tests + new_plan.groups = new_groups + new_plan.target_version = payload.plan.target_version + view = self.create_view_for_plan(new_plan) + new_plan.view_id = view.id + + new_plan.save() + + return new_plan + + + def check_plan_copy_eligibility(self, plan_id: str | UUID, target_release_id: str | UUID) -> dict: + target_release: ArgusRelease = ArgusRelease.get(id=target_release_id) + plan: ArgusReleasePlan = ArgusReleasePlan.get(id=plan_id) + original_release: ArgusRelease = ArgusRelease.get(id=plan.release_id) + + original_tests: list[ArgusTest] = ArgusTest.filter(id__in=plan.tests).all() + original_groups: list[ArgusGroup] = ArgusGroup.filter(id__in=plan.groups).all() + + target_tests: list[ArgusTest] = ArgusTest.filter(release_id=target_release.id).all() + target_groups: list[ArgusGroup] = ArgusGroup.filter(release_id=target_release.id).all() + + tests_by_build_id = { t.build_system_id: t for t in target_tests } + groups_by_build_id = { g.build_system_id: g for g in target_groups } + + missing_tests = [] + missing_groups = [] + status = "passed" + for test in original_tests: + new_build_id = test.build_system_id.replace(original_release.name, target_release.name, 1) + new_group = tests_by_build_id.get(new_build_id) + if not new_group: + t = TestLookup.index_mapper(test) + t["release"] = original_release.name + missing_tests.append(t) + + for group in original_groups: + new_build_id = group.build_system_id.replace(original_release.name, target_release.name, 1) + new_group = groups_by_build_id.get(new_build_id) + if not new_group: + g = TestLookup.index_mapper(group) + g["release"] = original_release.name + missing_groups.append(g) + + if len(missing_tests) > 0 or len(missing_groups) > 0: + status = "failed" + + return { + "status": status, + "targetRelease": target_release, + "originalRelease": original_release, + "missing": { + "tests": missing_tests, + "groups": missing_groups, + } + } + + + def release_planner(self, release_name: str) -> dict[str, Any]: + release: ArgusRelease = ArgusRelease.get(name=release_name) + + plans: list[ArgusReleasePlan] = self.get_plans_for_release(release.id) + + return { + "release": release, + "plans": plans, + } + + def get_plans_for_release(self, release_id: str | UUID) -> list[ArgusReleasePlan]: + return list(ArgusReleasePlan.filter(release_id=release_id).all()) + + def delete_plan(self, plan_id: str | UUID): + plan: ArgusReleasePlan = ArgusReleasePlan.get(id=plan_id) + if plan.view_id: + view: ArgusUserView = ArgusUserView.get(id=plan.view_id) + view.delete() + + plan.delete() + return True + + def get_assignee_for_test(self, test_id: str | UUID, target_version: str = None) -> UUID | None: + dml = ArgusReleasePlan.filter(tests__contains=test_id, complete=False) + if target_version: + dml.filter(target_version=target_version) + potential_plans: list[ArgusReleasePlan] = dml.allow_filtering().all() + for plan in potential_plans: + # Use the most recent plan + return plan.assignee_mapping.get(test_id, plan.owner) + return None + + def get_assignee_for_group(self, group_id: str | UUID, target_version: str = None) -> UUID | None: + dml = ArgusReleasePlan.filter(groups__contains=group_id, complete=False) + if target_version: + dml.filter(target_version=target_version) + potential_plans: list[ArgusReleasePlan] = dml.allow_filtering().all() + for plan in potential_plans: + # Use the most recent plan + return plan.assignee_mapping.get(group_id, plan.owner) + return None + + def get_assignments_for_groups(self, release_id: str | UUID, version: str = None, plan_id: UUID = None) -> dict[str, UUID]: + release: ArgusRelease = ArgusRelease.get(id=release_id) + if not plan_id: + plans: list[ArgusReleasePlan] = list(ArgusReleasePlan.filter(release_id=release.id).all()) + plans = plans if not version else [plan for plan in plans if plan.target_version == version] + else: + plans = [ArgusReleasePlan.get(id=plan_id)] + + all_assignments = {} + for plan in reversed(plans): + # TODO: (gid, [user_id]) Should be changed to gid, user_id once old scheduling mechanism is completely removed + all_assignments.update(map(lambda group_id: (str(group_id), [plan.assignee_mapping.get(group_id, plan.owner)]), plan.groups)) + + return all_assignments + + def get_assignments_for_tests(self, group_id: str | UUID, version: str = None, plan_id: UUID | str = None) -> dict[str, UUID]: + group: ArgusGroup = ArgusGroup.get(id=group_id) + release: ArgusRelease = ArgusRelease.get(id=group.release_id) + if not plan_id: + plans: list[ArgusReleasePlan] = list(ArgusReleasePlan.filter(release_id=release.id).all()) + plans = plans if not version else [plan for plan in plans if plan.target_version == version] + else: + plans = [ArgusReleasePlan.get(id=plan_id)] + + all_assignments = {} + def get_assignee(test_id: UUID, mapping: dict[UUID, UUID]): + test_assignment = mapping.get(test_id) + return test_assignment + + for plan in reversed(plans): + # TODO: (tid, [user_id]) Should be changed to tid, user_id once old scheduling mechanism is completely removed + all_assignments.update(map(lambda test_id: (str(test_id), [get_assignee(test_id, plan.assignee_mapping) or plan.owner]), plan.tests)) + + return all_assignments + + def complete_plan(self, plan_id: str | UUID) -> bool: + plan: ArgusReleasePlan = ArgusReleasePlan(id=plan_id).get() + plan.completed = True + + plan.save() + return plan.completed + + def resolve_plan(self, plan_id: str | UUID) -> list[dict[str, Any]]: + plan: ArgusReleasePlan = ArgusReleasePlan.get(id=plan_id) + + release: ArgusRelease = ArgusRelease.get(id=plan.release_id) + tests: list[ArgusTest] = list(ArgusTest.filter(id__in=plan.tests).all()) + test_groups: list[ArgusGroup] = ArgusGroup.filter(id__in=[t.group_id for t in tests]).all() + test_groups = { g.id: g for g in test_groups } + groups: list[ArgusGroup] = list(ArgusGroup.filter(id__in=plan.groups).all()) + + mapped = [TestLookup.index_mapper(entity, "group" if isinstance(entity, ArgusGroup) else "test") for entity in [*tests, *groups]] + + for ent in mapped: + ent["release"] = release.name + if group_id := ent.get("group_id"): + group = test_groups.get(group_id) + ent["group"] = group.pretty_name or group.name + + return mapped diff --git a/argus/backend/service/stats.py b/argus/backend/service/stats.py index c272f9ec..da219efa 100644 --- a/argus/backend/service/stats.py +++ b/argus/backend/service/stats.py @@ -7,11 +7,12 @@ from uuid import UUID from cassandra.cqlengine.models import Model +from argus.backend.models.plan import ArgusReleasePlan from argus.backend.plugins.loader import all_plugin_models from argus.backend.util.common import chunk, get_build_number from argus.common.enums import TestStatus, TestInvestigationStatus from argus.backend.models.web import ArgusGithubIssue, ArgusRelease, ArgusGroup, ArgusTest,\ - ArgusScheduleTest, ArgusTestRunComment, ArgusUserView + ArgusTestRunComment, ArgusUserView from argus.backend.db import ScyllaCluster LOGGER = logging.getLogger(__name__) @@ -154,7 +155,7 @@ def __init__(self, release: ArgusUserView) -> None: self.has_bug_report = False self.issues: list[ArgusGithubIssue] = [] self.comments: list[ArgusTestRunComment] = [] - self.test_schedules: dict[UUID, ArgusScheduleTest] = {} + self.plans: list[ArgusReleasePlan] = [] self.forced_collection = False self.rows = [] self.releases = {} @@ -192,15 +193,16 @@ def _fetch_multiple_release_queries(self, entity: Model, releases: list[str]): result_set.extend(entity.filter(release_id=release_id).all()) return result_set - def collect(self, rows: list[TestRunStatRow], limited=False, force=False, dict: dict[str, TestRunStatRow] | None = None, tests: list[ArgusTest] = None) -> None: + def collect(self, rows: list[TestRunStatRow], limited=False, force=False, dict: dict[str, TestRunStatRow] | None = None, tests: list[ArgusTest] = None, version_filter: str = None) -> None: self.forced_collection = force all_release_ids = list({t.release_id for t in tests}) if not limited: - self.test_schedules = reduce( - lambda acc, row: acc[row["test_id"]].append(row) or acc, - self._fetch_multiple_release_queries(ArgusScheduleTest, all_release_ids), - defaultdict(list) - ) + if self.release.plan_id: + plan = ArgusReleasePlan.get(id=self.release.plan_id) + self.plans = [plan] + else: + plans: list[ArgusReleasePlan] = [plan for release_id in all_release_ids for results in ArgusReleasePlan.filter(release_id=release_id).all() for plan in results] + self.plans = plans if not version_filter else [plan for plan in plans if version_filter == plan.target_version] self.rows = rows self.dict = dict @@ -245,7 +247,7 @@ def __init__(self, release: ArgusRelease) -> None: self.has_bug_report = False self.issues: list[ArgusGithubIssue] = [] self.comments: list[ArgusTestRunComment] = [] - self.test_schedules: dict[UUID, ArgusScheduleTest] = {} + self.plans: list[ArgusReleasePlan] = [] self.forced_collection = False self.rows = [] self.all_tests = [] @@ -275,17 +277,14 @@ def to_dict(self) -> dict: **aggregated_investigation_status } - def collect(self, rows: list[TestRunStatRow], limited=False, force=False, dict: dict | None = None, tests=None) -> None: + def collect(self, rows: list[TestRunStatRow], limited=False, force=False, dict: dict | None = None, tests=None, version_filter: str = None) -> None: self.forced_collection = force if not self.release.enabled and not force: return if not self.release.perpetual and not limited: - self.test_schedules = reduce( - lambda acc, row: acc[row["test_id"]].append(row) or acc, - ArgusScheduleTest.filter(release_id=self.release.id).all(), - defaultdict(list) - ) + plans: list[ArgusReleasePlan] = list(ArgusReleasePlan.filter(release_id=self.release.id).all()) + self.plans = plans if not filter else [plan for plan in plans if version_filter == plan.target_version] self.rows = rows self.dict = dict @@ -351,10 +350,18 @@ def collect(self, limited=False): for test in tests: if test.enabled: + is_scheduled = False + for plan in self.parent_release.plans: + if test.id in plan.tests: + is_scheduled = True + break + if self.group.id in plan.groups: + is_scheduled = True + break stats = TestStats( test=test, parent_group=self, - schedules=self.parent_release.test_schedules.get(test.id, []) + scheduled=is_scheduled ) stats.collect(limited=limited) self.tests.append(stats) @@ -371,7 +378,7 @@ def __init__( self, test: ArgusTest, parent_group: GroupStats, - schedules: list[ArgusScheduleTest] | None = None + scheduled: bool = False ) -> None: self.test = test self.parent_group = parent_group @@ -381,8 +388,7 @@ def __init__( self.last_runs: list[dict] = [] self.has_bug_report = False self.has_comments = False - self.schedules = schedules if schedules else tuple() - self.is_scheduled = len(self.schedules) > 0 + self.is_scheduled = scheduled self.tracked_run_number = None def to_dict(self) -> dict: @@ -492,7 +498,7 @@ def collect(self, limited=False, force=False, include_no_version=False) -> dict: self.release_dict[row["build_id"]] = runs self.release_stats = ReleaseStats(release=self.release) - self.release_stats.collect(rows=self.release_rows, limited=limited, force=force, dict=self.release_dict, tests=all_tests) + self.release_stats.collect(rows=self.release_rows, limited=limited, force=force, dict=self.release_dict, tests=all_tests, version_filter=self.release_version) return self.release_stats.to_dict() @@ -536,5 +542,5 @@ def collect(self, limited=False, force=False, include_no_version=False) -> dict: self.runs_by_build_id[row["build_id"]] = runs self.view_stats = ViewStats(release=self.view) - self.view_stats.collect(rows=self.view_rows, limited=limited, force=force, dict=self.runs_by_build_id, tests=all_tests) + self.view_stats.collect(rows=self.view_rows, limited=limited, force=force, dict=self.runs_by_build_id, tests=all_tests, version_filter=self.filter) return self.view_stats.to_dict() diff --git a/argus/backend/service/test_lookup.py b/argus/backend/service/test_lookup.py new file mode 100644 index 00000000..532f0461 --- /dev/null +++ b/argus/backend/service/test_lookup.py @@ -0,0 +1,109 @@ + + + +from functools import partial +import re +from urllib.parse import unquote +from typing import Callable +from uuid import UUID + +from cassandra.cqlengine.models import Model +from argus.backend.models.web import ArgusGroup, ArgusRelease, ArgusTest + + +class TestLookup: + ADD_ALL_ID = UUID("db6f33b2-660b-4639-ba7f-79725ef96616") + + @classmethod + def index_mapper(cls, item: Model, type = "test"): + mapped = dict(item) + mapped["type"] = type + return mapped + + @classmethod + def explode_group(cls, group_id: UUID | str): + group = ArgusGroup.get(id=group_id) + release = ArgusRelease.get(id=group.release_id) + tests = ArgusTest.filter(group_id=group.id).all() + + exploded = [] + for test in tests: + test = cls.index_mapper(test) + test["group"] = group.pretty_name or group.name + test["release"] = release.name + test["name"] = test["pretty_name"] or test["name"] + exploded.append(test) + + return exploded + + @classmethod + def test_lookup(cls, query: str, release_id: UUID | str = None): + def check_visibility(entity: dict): + if entity["type"] == "release" and release_id: + return False + if not entity["enabled"]: + return False + if entity.get("group") and not entity["group"]["enabled"]: + return False + if entity.get("release") and not entity["release"]["enabled"]: + return False + return True + + def facet_extraction(query: str) -> str: + extractor = re.compile(r"(?:(?P(?:release|group|type)):(?P\"?[\w\d\.\-]*\"?))") + facets = re.findall(extractor, query) + + return (re.sub(extractor, "", query).strip(), facets) + + def type_facet_filter(item: dict, key: str, facet_query: str): + entity_type: str = item[key] + return facet_query.lower() == entity_type + + def facet_filter(item: dict, key: str, facet_query: str): + if entity := item.get(key): + name: str = entity.get("pretty_name") or entity.get("name") + return facet_query.lower() in name.lower() if name else False + return False + + def facet_wrapper(query_func: Callable[[dict], bool], facet_query: str, facet_type: str) -> bool: + def inner(item: dict, query: str): + return query_func(item, query) and facet_funcs[facet_type](item, facet_type, facet_query) + return inner + + facet_funcs = { + "type": type_facet_filter, + "release": facet_filter, + "group": facet_filter, + } + + def index_searcher(item, query: str): + name: str = item["pretty_name"] or item["name"] + return unquote(query).lower() in name.lower() if query else True + + text_query, facets = facet_extraction(query) + search_func = index_searcher + for facet, value in facets: + if facet in facet_funcs.keys(): + search_func = facet_wrapper(query_func=search_func, facet_query=value, facet_type=facet) + + + if release_id: + all_releases = [ArgusRelease.get(id=release_id)] + else: + all_releases = ArgusRelease.objects().limit(None) + all_tests = ArgusTest.objects().limit(None) + all_groups = ArgusGroup.objects().limit(None) + if release_id: + all_tests = all_tests.filter(release_id=release_id) + all_groups = all_groups.filter(release_id=release_id) + release_by_id = {release.id: partial(cls.index_mapper, type="release")(release) for release in all_releases} + group_by_id = {group.id: partial(cls.index_mapper, type="group")(group) for group in all_groups} + index = [cls.index_mapper(t) for t in all_tests] + index = [*release_by_id.values(), *group_by_id.values(), *index] + for item in index: + item["group"] = group_by_id.get(item.get("group_id")) + item["release"] = release_by_id.get(item.get("release_id")) + + results = filter(partial(search_func, query=text_query), index) + + return [{ "id": cls.ADD_ALL_ID, "name": "Add all...", "type": "special" }, *list(res for res in results if check_visibility(res))] \ No newline at end of file diff --git a/argus/backend/service/views.py b/argus/backend/service/views.py index 8c89edc8..9a5fcb7a 100644 --- a/argus/backend/service/views.py +++ b/argus/backend/service/views.py @@ -1,14 +1,14 @@ import datetime import logging -import re from functools import partial, reduce -from typing import Any, Callable, TypedDict -from urllib.parse import unquote +from typing import TypedDict from uuid import UUID from cassandra.cqlengine.models import Model +from argus.backend.models.plan import ArgusReleasePlan from argus.backend.models.web import ArgusGroup, ArgusRelease, ArgusTest, ArgusUserView, User from argus.backend.plugins.loader import all_plugin_models +from argus.backend.service.test_lookup import TestLookup from argus.backend.util.common import chunk, current_user LOGGER = logging.getLogger(__name__) @@ -27,8 +27,7 @@ class ViewUpdateRequest(TypedDict): class UserViewService: - ADD_ALL_ID = UUID("db6f33b2-660b-4639-ba7f-79725ef96616") - def create_view(self, name: str, items: list[str], widget_settings: str, description: str = None, display_name: str = None) -> ArgusUserView: + def create_view(self, name: str, items: list[str], widget_settings: str, description: str = None, display_name: str = None, plan_id: UUID = None) -> ArgusUserView: try: name_check = ArgusUserView.get(name=name) raise UserViewException(f"View with name {name} already exists: {name_check.id}", name, name_check, name_check.id) @@ -39,6 +38,7 @@ def create_view(self, name: str, items: list[str], widget_settings: str, descrip view.display_name = display_name or name view.description = description view.widget_settings = widget_settings + view.plan_id = plan_id view.tests = [] for entity in items: entity_type, entity_id = entity.split(":") @@ -56,74 +56,8 @@ def create_view(self, name: str, items: list[str], widget_settings: str, descrip view.save() return view - @staticmethod - def index_mapper(item: Model, type = "test"): - mapped = dict(item) - mapped["type"] = type - return mapped - def test_lookup(self, query: str): - def check_visibility(entity: dict): - if not entity["enabled"]: - return False - if entity.get("group") and not entity["group"]["enabled"]: - return False - if entity.get("release") and not entity["release"]["enabled"]: - return False - return True - - def facet_extraction(query: str) -> str: - extractor = re.compile(r"(?:(?P(?:release|group|type)):(?P\"?[\w\d\.\-]*\"?))") - facets = re.findall(extractor, query) - - return (re.sub(extractor, "", query).strip(), facets) - - def type_facet_filter(item: dict, key: str, facet_query: str): - entity_type: str = item[key] - return facet_query.lower() == entity_type - - def facet_filter(item: dict, key: str, facet_query: str): - if entity := item.get(key): - name: str = entity.get("pretty_name") or entity.get("name") - return facet_query.lower() in name.lower() if name else False - return False - - def facet_wrapper(query_func: Callable[[dict], bool], facet_query: str, facet_type: str) -> bool: - def inner(item: dict, query: str): - return query_func(item, query) and facet_funcs[facet_type](item, facet_type, facet_query) - return inner - - facet_funcs = { - "type": type_facet_filter, - "release": facet_filter, - "group": facet_filter, - } - - def index_searcher(item, query: str): - name: str = item["pretty_name"] or item["name"] - return unquote(query).lower() in name.lower() if query else True - - text_query, facets = facet_extraction(query) - search_func = index_searcher - for facet, value in facets: - if facet in facet_funcs.keys(): - search_func = facet_wrapper(query_func=search_func, facet_query=value, facet_type=facet) - - - all_tests = ArgusTest.objects().limit(None) - all_releases = ArgusRelease.objects().limit(None) - all_groups = ArgusGroup.objects().limit(None) - release_by_id = {release.id: partial(self.index_mapper, type="release")(release) for release in all_releases} - group_by_id = {group.id: partial(self.index_mapper, type="group")(group) for group in all_groups} - index = [self.index_mapper(t) for t in all_tests] - index = [*release_by_id.values(), *group_by_id.values(), *index] - for item in index: - item["group"] = group_by_id.get(item.get("group_id")) - item["release"] = release_by_id.get(item.get("release_id")) - - results = filter(partial(search_func, query=text_query), index) - - return [{ "id": self.ADD_ALL_ID, "name": "Add all...", "type": "special" }, *list(res for res in results if check_visibility(res))] + return TestLookup.test_lookup(query) def update_view(self, view_id: str | UUID, update_data: ViewUpdateRequest) -> bool: view: ArgusUserView = ArgusUserView.get(id=view_id) @@ -195,7 +129,15 @@ def batch_resolve_entity(self, entity: Model, param_name: str, entity_ids: list[ return result def refresh_stale_view(self, view: ArgusUserView): - view.tests = [test.id for test in self.resolve_view_tests(view.id)] + if view.plan_id: + try: + view.tests = [test.id for test in self.resolve_tests_by_id(ArgusReleasePlan.get(id=view.plan_id).tests)] + view.group_ids = ArgusReleasePlan.get(id=view.plan_id).groups + except ArgusReleasePlan.DoesNotExist: + LOGGER.warning("Dangling view %s from non-existent release plan %s", view.id, view.plan_id) + return view + else: + view.tests = [test.id for test in self.resolve_view_tests(view.id)] all_tests = set(view.tests) all_tests.update(test.id for test in self.batch_resolve_entity(ArgusTest, "group_id", view.group_ids)) all_tests.update(test.id for test in self.batch_resolve_entity(ArgusTest, "release_id", view.release_ids)) @@ -234,10 +176,10 @@ def resolve_view_for_edit(self, view_id: str | UUID) -> dict: view_groups = self.batch_resolve_entity(ArgusGroup, "id", view.group_ids) view_releases = self.batch_resolve_entity(ArgusRelease, "id", view.release_ids) view_tests = self.resolve_view_tests(view.id) - all_groups = { group.id: partial(self.index_mapper, type="group")(group) for group in self.resolve_releases_for_tests(view_tests) } - all_releases ={ release.id: partial(self.index_mapper, type="release")(release) for release in self.resolve_releases_for_tests(view_tests) } + all_groups = { group.id: partial(TestLookup.index_mapper, type="group")(group) for group in self.resolve_releases_for_tests(view_tests) } + all_releases ={ release.id: partial(TestLookup.index_mapper, type="release")(release) for release in self.resolve_releases_for_tests(view_tests) } entities_by_id = { - entity.id: partial(self.index_mapper, type="release" if isinstance(entity, ArgusRelease) else "group")(entity) + entity.id: partial(TestLookup.index_mapper, type="release" if isinstance(entity, ArgusRelease) else "group")(entity) for container in [view_releases, view_groups] for entity in container } diff --git a/argus/backend/util/encoders.py b/argus/backend/util/encoders.py index 320973fd..f9bf4855 100644 --- a/argus/backend/util/encoders.py +++ b/argus/backend/util/encoders.py @@ -27,14 +27,28 @@ def default(self, o): class ArgusJSONProvider(DefaultJSONProvider): - def default(self, o): + @staticmethod + def process_nested_dicts(o: dict): + for k, v in o.items(): + if isinstance(v, dict): + o[k] = { str(key): val for key, val in v.items() } + return o + + @classmethod + def default(cls, o): match o: case UUID(): return str(o) case ut.UserType(): - return dict(o.items()) + o = { str(k): v for k, v in o.items() } + o = cls.process_nested_dicts(o) + return o case m.Model(): - return dict(o.items()) + o = { str(k): v for k, v in o.items() } + o = cls.process_nested_dicts(o) + return o + case dict(): + return { str(k): v for k, v in o.items() } case datetime(): return o.strftime("%Y-%m-%dT%H:%M:%SZ") case _: diff --git a/argus_backend.py b/argus_backend.py index 257913b3..c21d93e3 100644 --- a/argus_backend.py +++ b/argus_backend.py @@ -4,7 +4,7 @@ from argus.backend.controller import admin, api, main from argus.backend.cli import cli_bp from argus.backend.util.logsetup import setup_application_logging -from argus.backend.util.encoders import ArgusJSONProvider +from argus.backend.util.encoders import ArgusJSONEncoder, ArgusJSONProvider from argus.backend.db import ScyllaCluster from argus.backend.controller import auth from argus.backend.util.config import Config @@ -16,6 +16,7 @@ def start_server(config=None) -> Flask: app = Flask(__name__, static_url_path="/s/", static_folder="public") app.json_provider_class = ArgusJSONProvider app.json = ArgusJSONProvider(app) + app.jinja_env.policies["json.dumps_kwargs"]["default"] = app.json.default app.config.from_mapping(Config.load_yaml_config()) if config: app.config.from_mapping(config) diff --git a/frontend/Common/ModalWindow.svelte b/frontend/Common/ModalWindow.svelte index 9a13cdf2..2ae095b8 100644 --- a/frontend/Common/ModalWindow.svelte +++ b/frontend/Common/ModalWindow.svelte @@ -2,11 +2,14 @@ import { createEventDispatcher } from "svelte"; const dispatch = createEventDispatcher(); + + export let widthClass = "h-50"; +