diff --git a/app/blueprints/packages/packages.py b/app/blueprints/packages/packages.py index b9da2888..88f7f93b 100644 --- a/app/blueprints/packages/packages.py +++ b/app/blueprints/packages/packages.py @@ -34,9 +34,9 @@ from app.logic.LogicError import LogicError from app.logic.packages import do_edit_package from app.querybuilder import QueryBuilder from app.rediscache import has_key, set_temp_key -from app.tasks.importtasks import import_repo_screenshot, check_zip_release +from app.tasks.importtasks import import_repo_screenshot, check_zip_release, remove_package_game_support, \ + update_package_game_support from app.tasks.webhooktasks import post_discord_webhook -from app.logic.game_support import GameSupportResolver from . import bp, get_package_tabs from app.models import Package, Tag, db, User, Tags, PackageState, Permission, PackageType, MetaPackage, ForumTopic, \ @@ -46,6 +46,7 @@ from app.models import Package, Tag, db, User, Tags, PackageState, Permission, P from app.utils import is_user_bot, get_int_or_abort, is_package_page, abs_url_for, add_audit_log, get_package_by_info, \ add_notification, get_system_user, rank_required, get_games_from_csv, get_daterange_options, post_to_approval_thread from app.logic.package_approval import validate_package_for_approval, can_move_to_state +from app.logic.game_support import game_support_set @bp.route("/packages/") @@ -409,6 +410,7 @@ def move_to_state(package): s.approved = True msg = "Approved {}".format(package.title) + update_package_game_support.delay(package.id) elif state == PackageState.READY_FOR_REVIEW: post_discord_webhook.delay(package.author.display_name, "Ready for Review: {}".format(package.get_url("packages.view", absolute=True)), True, @@ -478,6 +480,8 @@ def remove(package): f"Deleted package {package.author.username}/{package.name} with reason '{reason}'", True, package.title, package.short_desc, package.get_thumb_url(2, True, "png")) + remove_package_game_support.delay(package.id) + flash(gettext("Deleted package"), "success") return redirect(url) @@ -498,6 +502,8 @@ def remove(package): "Unapproved package with reason {}\n\n{}".format(reason, package.get_url("packages.view", absolute=True)), True, package.title, package.short_desc, package.get_thumb_url(2, True, "png")) + remove_package_game_support.delay(package.id) + flash(gettext("Unapproved package"), "success") return redirect(package.get_url("packages.view")) @@ -724,14 +730,12 @@ def game_support(package): if can_override: try: - resolver = GameSupportResolver(db.session) - game_is_supported = {} for game in get_games_from_csv(db.session, form.supported.data or ""): game_is_supported[game.id] = True for game in get_games_from_csv(db.session, form.unsupported.data or ""): game_is_supported[game.id] = False - resolver.set_supported(package, game_is_supported, 11) + game_support_set(db.session, package, game_is_supported, 11) detect_update_needed = True except LogicError as e: flash(e.message, "danger") diff --git a/app/logic/game_support.py b/app/logic/game_support.py index c24b4705..687a0db0 100644 --- a/app/logic/game_support.py +++ b/app/logic/game_support.py @@ -1,5 +1,5 @@ # ContentDB -# Copyright (C) 2022 rubenwardy +# Copyright (C) rubenwardy # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -14,31 +14,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from typing import List, Dict, Optional -import sys -from typing import List, Dict +import sqlalchemy -import sqlalchemy.orm - -from app.logic.LogicError import LogicError -from app.models import Package, MetaPackage, PackageType, PackageState, PackageGameSupport - -""" -get_game_support(package): - if package is a game: - return [ package ] - - for all hard dependencies: - support = support AND get_meta_package_support(dep) - - return support - -get_meta_package_support(meta): - for package implementing mod name: - support = support OR get_game_support(package) - - return support -""" +from app.models import PackageType, Package, PackageState, PackageGameSupport +from app.utils import post_bot_message minetest_game_mods = { @@ -55,123 +36,319 @@ mtg_mod_blacklist = { } -class GameSupportResolver: - session: sqlalchemy.orm.Session - checked_packages = set() - checked_modnames = set() - resolved_packages: Dict[int, set[int]] = {} - resolved_modnames: Dict[int, set[int]] = {} +class GSPackage: + author: str + name: str + type: PackageType - def __init__(self, session): - self.session = session + provides: set[str] + depends: set[str] - def resolve_for_meta_package(self, meta: MetaPackage, history: List[str]) -> set[int]: - print(f"Resolving for {meta.name}", file=sys.stderr) + user_supported_games: set[str] + user_unsupported_games: set[str] + detected_supported_games: set[str] + supports_all_games: bool - key = meta.name - if key in self.resolved_modnames: - return self.resolved_modnames.get(key) + detection_disabled: bool - if key in self.checked_modnames: - print(f"Error, cycle found: {','.join(history)}", file=sys.stderr) - return set() + is_confirmed: bool + errors: set[str] - self.checked_modnames.add(key) + def __init__(self, author: str, name: str, type: PackageType, provides: set[str]): + self.author = author + self.name = name + self.type = type + self.provides = provides + self.depends = set() + self.user_supported_games = set() + self.user_unsupported_games = set() + self.detected_supported_games = set() + self.supports_all_games = False + self.detection_disabled = False + self.is_confirmed = type == PackageType.GAME + self.errors = set() - retval = set() + # For dodgy games, discard MTG mods + if self.type == PackageType.GAME and self.name in mtg_mod_blacklist: + self.provides.difference_update(minetest_game_mods) - for package in meta.packages: - if package.state != PackageState.APPROVED: - continue + @property + def id_(self) -> str: + return f"{self.author}/{self.name}" - if meta.name in minetest_game_mods and package.name in mtg_mod_blacklist: - continue + @property + def supported_games(self) -> set[str]: + ret = set() + ret.update(self.user_supported_games) + if not self.detection_disabled: + ret.update(self.detected_supported_games) + ret.difference_update(self.user_unsupported_games) + return ret - ret = self.resolve(package, history) - if len(ret) == 0: - retval = set() + @property + def unsupported_games(self) -> set[str]: + return self.user_unsupported_games + + +class GameSupport: + packages: Dict[str, GSPackage] + modified_packages: set[GSPackage] + + def __init__(self): + self.packages = {} + self.modified_packages = set() + + @property + def all_confirmed(self): + return all([x.is_confirmed for x in self.packages.values()]) + + @property + def has_errors(self): + return any([len(x.errors) > 0 for x in self.packages.values()]) + + @property + def error_count(self): + return sum([len(x.errors) for x in self.packages.values()]) + + @property + def all_errors(self) -> set[str]: + errors = set() + for package in self.packages.values(): + for err in package.errors: + errors.add(package.id_ + ": " + err) + return errors + + def add(self, package: GSPackage) -> GSPackage: + self.packages[package.id_] = package + return package + + def get(self, id_: str) -> Optional[GSPackage]: + return self.packages[id_] + + def get_all_that_provide(self, modname: str) -> List[GSPackage]: + return [package for package in self.packages.values() if modname in package.provides] + + def get_all_that_depend_on(self, modname: str) -> List[GSPackage]: + return [package for package in self.packages.values() if modname in package.depends] + + def _get_supported_games_for_modname(self, depend: str, visited: list[str]): + dep_supports_all = False + for_dep = set() + + for provider in self.get_all_that_provide(depend): + found_in = self._get_supported_games(provider, visited) + if found_in is None: + # Unsupported, keep going + pass + elif len(found_in) == 0: + dep_supports_all = True break + else: + for_dep.update(found_in) - retval.update(ret) + return dep_supports_all, for_dep - self.resolved_modnames[key] = retval - return retval + def _get_supported_games_for_deps(self, package: GSPackage, visited: list[str]) -> Optional[set[str]]: + ret = set() - def resolve(self, package: Package, history: List[str]) -> set[int]: - key: int = package.id - print(f"Resolving for {key}", file=sys.stderr) + for depend in package.depends: + dep_supports_all, for_dep = self._get_supported_games_for_modname(depend, visited) - history = history.copy() - history.append(package.get_id()) + if dep_supports_all: + # Dep is game independent + pass + elif len(for_dep) == 0: + package.errors.add(f"Unable to fulfill dependency {depend}") + return None + elif len(ret) == 0: + ret = for_dep + else: + ret.intersection_update(for_dep) + if len(ret) == 0: + package.errors.add("Game support conflict, unable to install package on any games") + return None + + return ret + + def _get_supported_games(self, package: GSPackage, visited: list[str]) -> Optional[set[str]]: + if package.id_ in visited: + first_idx = visited.index(package.id_) + visited = visited[first_idx:] + err = f"Dependency cycle detected: {' -> '.join(visited)} -> {package.id_}" + for id_ in visited: + package2 = self.get(id_) + package2.errors.add(err) + return None + + visited = visited.copy() + visited.append(package.id_) if package.type == PackageType.GAME: - return {package.id} + return {package.name} + elif package.is_confirmed: + return package.supported_games + else: + ret = self._get_supported_games_for_deps(package, visited) + if ret is None: + assert len(package.errors) > 0 + return None - if key in self.resolved_packages: - return self.resolved_packages.get(key) + ret = ret.copy() + ret.difference_update(package.user_unsupported_games) + package.detected_supported_games = ret + self.modified_packages.add(package) - if key in self.checked_packages: - print(f"Error, cycle found: {','.join(history)}", file=sys.stderr) - return set() + if len(ret) > 0: + for supported in package.user_supported_games: + if supported not in ret: + package.errors.add(f"`{supported}` is specified in supported_games but it is impossible to run {package.name} in that game. " + + f"Its dependencies can only be fulfilled in {', '.join([f'`{x}`' for x in ret])}. " + + "Check your hard dependencies.") - self.checked_packages.add(key) + if package.supports_all_games: + package.errors.add( + "This package cannot support all games as some dependencies require specific game(s): " + + ", ".join([f'`{x}`' for x in ret])) - if package.type != PackageType.MOD: - raise LogicError(500, "Got non-mod") + package.is_confirmed = True + return package.supported_games - retval = set() + def on_update(self, package: GSPackage): + to_update = {package} + checked = set() - for dep in package.dependencies.filter_by(optional=False).all(): - ret = self.resolve_for_meta_package(dep.meta_package, history) - if len(ret) == 0: - continue - elif len(retval) == 0: - retval.update(ret) - else: - retval.intersection_update(ret) - if len(retval) == 0: - raise LogicError(500, f"Detected game support contradiction, {key} may not be compatible with any games") + while len(to_update) > 0: + current_package = to_update.pop() + if current_package.id_ in self.packages and current_package.type != PackageType.GAME: + current_package.is_confirmed = False + current_package.detected_supported_games = [] + self._get_supported_games(current_package, []) - self.resolved_packages[key] = retval - return retval + for modname in current_package.provides: + for depending_package in self.get_all_that_depend_on(modname): + if depending_package not in checked: + to_update.add(depending_package) + checked.add(depending_package) - def init_all(self) -> None: - for package in self.session.query(Package).filter(Package.type == PackageType.MOD, Package.state != PackageState.DELETED).all(): - retval = self.resolve(package, []) - for game_id in retval: - game = self.session.query(Package).get(game_id) - support = PackageGameSupport(package, game, 1, True) - self.session.add(support) + def on_remove(self, package: GSPackage): + del self.packages[package.id_] + self.on_update(package) - """ - Update game supported package on a package, given the confidence. - - Higher confidences outweigh lower ones. - """ - def set_supported(self, package: Package, game_is_supported: Dict[int, bool], confidence: int): - previous_supported: Dict[int, PackageGameSupport] = {} - for support in package.supported_games.all(): - previous_supported[support.game.id] = support + def on_first_run(self): + for package in self.packages.values(): + if not package.is_confirmed: + self.on_update(package) - for game_id, supports in game_is_supported.items(): - game = self.session.query(Package).get(game_id) - lookup = previous_supported.pop(game_id, None) - if lookup is None: - support = PackageGameSupport(package, game, confidence, supports) - self.session.add(support) - elif lookup.confidence <= confidence: - lookup.supports = supports - lookup.confidence = confidence - for game, support in previous_supported.items(): - if support.confidence == confidence: - self.session.delete(support) +def _convert_package(support: GameSupport, package: Package) -> GSPackage: + # Unapproved packages shouldn't be considered to fulfill anything + provides = set() + if package.state == PackageState.APPROVED: + provides = set([x.name for x in package.provides]) - def update(self, package: Package) -> None: - game_is_supported = {} - if package.enable_game_support_detection: - retval = self.resolve(package, []) - for game_id in retval: - game_is_supported[game_id] = True + gs_package = GSPackage(package.author.username, package.name, package.type, provides) + gs_package.depends = set([x.meta_package.name for x in package.dependencies if not x.optional]) + gs_package.detection_disabled = not package.enable_game_support_detection + gs_package.supports_all_games = package.supports_all_games - self.set_supported(package, game_is_supported, 1) + existing_game_support = (package.supported_games + .filter(PackageGameSupport.game.has(state=PackageState.APPROVED), + PackageGameSupport.confidence > 5) + .all()) + gs_package.user_supported_games = [x.game.name for x in existing_game_support if x.supports] + gs_package.user_unsupported_games = [x.game.name for x in existing_game_support if not x.supports] + return support.add(gs_package) + + +def _create_instance(session: sqlalchemy.orm.Session) -> GameSupport: + support = GameSupport() + + packages: List[Package] = (session.query(Package) + .filter(Package.state == PackageState.APPROVED, Package.type.in_([PackageType.GAME, PackageType.MOD])) + .all()) + + for package in packages: + _convert_package(support, package) + + return support + + +def _persist(session: sqlalchemy.orm.Session, support: GameSupport): + for gs_package in support.packages.values(): + if len(gs_package.errors) != 0: + msg = "\n".join([f"- {x}" for x in gs_package.errors]) + package = session.query(Package).filter( + Package.author.has(username=gs_package.author), + Package.name == gs_package.name).one() + post_bot_message(package, "Error when checking game support", msg, session) + + for gs_package in support.modified_packages: + if not gs_package.detection_disabled: + package = session.query(Package).filter( + Package.author.has(username=gs_package.author), + Package.name == gs_package.name).one() + + # Clear existing + session.query(PackageGameSupport) \ + .filter_by(package=package, confidence=1) \ + .delete() + + # Add new + supported_games = gs_package.supported_games \ + .difference(gs_package.user_supported_games) + for game_name in supported_games: + game_id = session.query(Package.id) \ + .filter(Package.type == PackageType.GAME, Package.name == game_name, Package.state == PackageState.APPROVED) \ + .one()[0] + + new_support = PackageGameSupport() + new_support.package = package + new_support.game_id = game_id + new_support.confidence = 1 + new_support.supports = True + session.add(new_support) + + +def game_support_update(session: sqlalchemy.orm.Session, package: Package) -> set[str]: + support = _create_instance(session) + gs_package = support.get(package.get_id()) + if gs_package is None: + gs_package = _convert_package(support, package) + support.on_update(gs_package) + _persist(session, support) + return gs_package.errors + + +def game_support_update_all(session: sqlalchemy.orm.Session): + support = _create_instance(session) + support.on_first_run() + _persist(session, support) + + +def game_support_remove(session: sqlalchemy.orm.Session, package: Package): + support = _create_instance(session) + gs_package = support.get(package.get_id()) + if gs_package is None: + gs_package = _convert_package(support, package) + support.on_remove(gs_package) + _persist(session, support) + + +def game_support_set(session, package: Package, game_is_supported: Dict[int, bool], confidence: int): + previous_supported: Dict[int, PackageGameSupport] = {} + for support in package.supported_games.all(): + previous_supported[support.game.id] = support + + for game_id, supports in game_is_supported.items(): + game = session.query(Package).get(game_id) + lookup = previous_supported.pop(game_id, None) + if lookup is None: + support = PackageGameSupport(package, game, confidence, supports) + session.add(support) + elif lookup.confidence <= confidence: + lookup.supports = supports + lookup.confidence = confidence + + for game, support in previous_supported.items(): + if support.confidence == confidence: + session.delete(support) diff --git a/app/models/packages.py b/app/models/packages.py index 13e15c91..72f7122a 100644 --- a/app/models/packages.py +++ b/app/models/packages.py @@ -329,12 +329,6 @@ class PackageGameSupport(db.Model): __table_args__ = (db.UniqueConstraint("game_id", "package_id", name="_package_game_support_uc"),) - def __init__(self, package, game, confidence, supports): - self.package = package - self.game = game - self.confidence = confidence - self.supports = supports - class Package(db.Model): query_class = PackageQuery @@ -531,14 +525,14 @@ class Package(db.Model): def get_sorted_optional_dependencies(self): return self.get_sorted_dependencies(False) - def get_sorted_game_support(self): + def get_sorted_game_support(self) -> list[PackageGameSupport]: query = self.supported_games.filter(PackageGameSupport.game.has(state=PackageState.APPROVED)) supported = query.all() supported.sort(key=lambda x: -(x.game.score + 100000*x.confidence)) return supported - def get_sorted_game_support_pair(self): + def get_sorted_game_support_pair(self) -> list[list[PackageGameSupport]]: supported = self.get_sorted_game_support() return [ [x for x in supported if x.supports], diff --git a/app/tasks/importtasks.py b/app/tasks/importtasks.py index 08a92a68..12185b02 100644 --- a/app/tasks/importtasks.py +++ b/app/tasks/importtasks.py @@ -40,8 +40,8 @@ from .minetestcheck import build_tree, MinetestCheckError, ContentType, PackageT from .webhooktasks import post_discord_webhook from app import app from app.logic.LogicError import LogicError -from app.logic.game_support import GameSupportResolver from app.logic.packages import do_edit_package, ALIASES +from app.logic.game_support import game_support_update, game_support_set, game_support_update_all, game_support_remove from app.utils.image import get_image_size @@ -90,8 +90,21 @@ def get_meta(urlstr, author): @celery.task() def update_all_game_support(): - resolver = GameSupportResolver(db.session) - resolver.init_all() + game_support_update_all(db.session) + db.session.commit() + + +@celery.task() +def update_package_game_support(package_id: int): + package = Package.query.get(package_id) + game_support_update(db.session, package) + db.session.commit() + + +@celery.task() +def remove_package_game_support(package_id: int): + package = Package.query.get(package_id) + game_support_remove(db.session, package) db.session.commit() @@ -186,8 +199,6 @@ def post_release_check_update(self, release: PackageRelease, path): # Update game support if package.type == PackageType.MOD or package.type == PackageType.TXP: - resolver = GameSupportResolver(db.session) - game_is_supported = {} if "supported_games" in tree.meta: for game in get_games_from_list(db.session, tree.meta["supported_games"]): @@ -205,17 +216,21 @@ def post_release_check_update(self, release: PackageRelease, path): for game in get_games_from_list(db.session, tree.meta["unsupported_games"]): game_is_supported[game.id] = False - resolver.set_supported(package, game_is_supported, 10) + game_support_set(db.session, package, game_is_supported, 10) if package.type == PackageType.MOD: - resolver.update(package) + errors = game_support_update(db.session, package) + if len(errors) != 0: + raise TaskError("Error validating game support:\n\n" + "\n".join([f"- {x}" for x in errors])) return tree except (MinetestCheckError, TaskError, LogicError) as err: db.session.rollback() + error_message = err.value if hasattr(err, "value") else str(err) + task_url = url_for('tasks.check', id=self.request.id) - msg = f"{err}\n\n[View Release]({release.get_edit_url()}) | [View Task]({task_url})" + msg = f"{error_message}\n\n[View Release]({release.get_edit_url()}) | [View Task]({task_url})" post_bot_message(release.package, f"Release {release.title} validation failed", msg) if "Fails validation" not in release.title: @@ -225,7 +240,7 @@ def post_release_check_update(self, release: PackageRelease, path): release.approved = False db.session.commit() - raise TaskError(str(err)) + raise TaskError(error_message) def update_translations(package: Package, tree: PackageTreeNode): diff --git a/app/tests/unit/logic/test_game_support.py b/app/tests/unit/logic/test_game_support.py new file mode 100644 index 00000000..1c1bac3f --- /dev/null +++ b/app/tests/unit/logic/test_game_support.py @@ -0,0 +1,517 @@ +# ContentDB +# Copyright (C) rubenwardy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from typing import List + +from app.logic.game_support import GSPackage, GameSupport +from app.models import PackageType + + +def make_mod(name: str, provides: List[str], deps: List[str]) -> GSPackage: + ret = GSPackage("author", name, PackageType.MOD, set(provides)) + ret.depends.update(deps) + return ret + + +def make_game(name: str, provides: List[str]) -> GSPackage: + return GSPackage("author", name, PackageType.GAME, set(provides)) + + +def test_game_supports_itself(): + """ + Games obviously support themselves + """ + support = GameSupport() + game = support.add(make_game("game1", ["default"])) + + assert not support.has_errors + assert game.is_confirmed + assert len(game.detected_supported_games) == 0 + + support.on_update(game) + + assert not support.has_errors + assert game.is_confirmed + assert len(game.detected_supported_games) == 0 + + +def test_no_deps(): + """ + Test a mod with no dependencies supports all games + """ + support = GameSupport() + support.add(make_game("game1", ["default"])) + mod1 = support.add(make_mod("mod1", ["mod1"], [])) + support.on_update(mod1) + + assert not support.has_errors + assert mod1.is_confirmed + assert len(mod1.detected_supported_games) == 0 + + +def test_direct_game_dep(): + """ + Test that depending on a mod in a game works + """ + support = GameSupport() + support.add(make_game("game1", ["default"])) + mod1 = support.add(make_mod("mod1", ["mod1"], ["default"])) + support.on_update(mod1) + + assert not support.has_errors + assert mod1.is_confirmed + assert mod1.detected_supported_games == {"game1"} + + +def test_indirect_game_dep(): + """ + Test that depending on a mod that depends on a game works + """ + support = GameSupport() + support.add(make_game("game1", ["default"])) + mod1 = support.add(make_mod("mod1", ["mod1"], ["default"])) + mod2 = support.add(make_mod("mod2", ["mod2"], ["mod1"])) + support.on_update(mod2) + + assert not support.has_errors + assert mod1.is_confirmed + assert mod1.detected_supported_games == {"game1"} + assert mod2.is_confirmed + assert mod2.detected_supported_games == {"game1"} + + +def test_multiple_game_dep(): + """ + Test with multiple games, with dependencies in games and as standalone mods + """ + support = GameSupport() + support.add(make_game("game1", ["default"])) + support.add(make_game("game2", ["core", "mod_b"])) + lib = support.add(make_mod("lib", ["lib"], [])) + modB = support.add(make_mod("mod_b", ["mod_b"], ["default"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b", "lib"])) + support.on_update(modA) + + assert not support.has_errors + + assert modA.is_confirmed + assert modA.detected_supported_games == {"game1", "game2"} + + assert modB.is_confirmed + assert modB.detected_supported_games == {"game1"} + + assert lib.is_confirmed + assert len(lib.detected_supported_games) == 0 + + +def test_dependency_supports_all(): + """ + Test with dependencies that support all games + """ + support = GameSupport() + support.add(make_game("game1", ["default"])) + mod1 = support.add(make_mod("mod1", ["mod1"], ["default"])) + lib = support.add(make_mod("lib", ["lib"], [])) + mod2 = support.add(make_mod("mod2", ["mod2"], ["mod1", "lib"])) + support.on_update(mod2) + + assert not support.has_errors + + assert mod1.is_confirmed + assert mod1.detected_supported_games == {"game1"} + + assert mod2.is_confirmed + assert mod2.detected_supported_games == {"game1"} + + assert lib.is_confirmed + assert len(lib.detected_supported_games) == 0 + + +def test_dependency_supports_all2(): + """ + Test with dependencies that support all games, but are also in games + """ + support = GameSupport() + support.add(make_game("game1", ["default", "lib"])) + lib = support.add(make_mod("lib", ["lib"], [])) + mod1 = support.add(make_mod("mod1", ["mod1"], ["lib"])) + support.on_update(mod1) + + assert not support.has_errors + + assert mod1.is_confirmed + assert len(mod1.detected_supported_games) == 0 + + assert lib.is_confirmed + assert len(lib.detected_supported_games) == 0 + + +def test_dependency_game_conflict(): + """ + Test situation where a mod is not installable in any games + """ + support = GameSupport() + support.add(make_game("game1", ["default", "mod_b"])) + support.add(make_game("game2", ["default", "mod_c"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b", "mod_c"])) + support.on_update(modA) + + assert not modA.is_confirmed + assert len(modA.detected_supported_games) == 0 + + assert support.all_errors == { + "author/mod_a: Game support conflict, unable to install package on any games" + } + + +def test_missing_hard_dep(): + """ + Test missing hard dependency + """ + support = GameSupport() + support.add(make_game("game1", ["default"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["nonexist"])) + support.on_update(modA) + + assert not modA.is_confirmed + assert len(modA.detected_supported_games) == 0 + assert support.all_errors == { + "author/mod_a: Unable to fulfill dependency nonexist" + } + + +def test_cycle(): + """ + Test for dependency cycles + """ + support = GameSupport() + support.add(make_game("game1", ["default"])) + support.add(make_mod("mod_b", ["mod_b"], ["mod_a"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b"])) + support.on_update(modA) + + assert support.all_errors == { + "author/mod_a: Dependency cycle detected: author/mod_b -> author/mod_a -> author/mod_b", + "author/mod_b: Dependency cycle detected: author/mod_a -> author/mod_b -> author/mod_a", + "author/mod_a: Dependency cycle detected: author/mod_a -> author/mod_b -> author/mod_a", + "author/mod_b: Unable to fulfill dependency mod_a", + "author/mod_b: Dependency cycle detected: author/mod_b -> author/mod_a -> author/mod_b", + "author/mod_a: Unable to fulfill dependency mod_b" + } + + +def test_cycle_fails_safely(): + """ + A dependency cycle shouldn't completely break the graph if a mod is + available elsewhere + """ + support = GameSupport() + support.add(make_game("game1", ["default", "mod_d"])) + modC = support.add(make_mod("mod_c", ["mod_c"], ["mod_b"])) + modB = support.add(make_mod("mod_b", ["mod_b"], ["mod_c"])) + support.add(make_mod("mod_d", ["mod_d"], ["mod_b"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_d"])) + support.on_update(modA) + + assert not modC.is_confirmed + assert not modB.is_confirmed + assert modA.is_confirmed + assert len(modA.errors) == 0 + assert modA.detected_supported_games == {"game1"} + + assert support.all_errors == { + "author/mod_b: Unable to fulfill dependency mod_c", + "author/mod_d: Unable to fulfill dependency mod_b", + "author/mod_c: Unable to fulfill dependency mod_b", + "author/mod_c: Dependency cycle detected: author/mod_b -> author/mod_c -> author/mod_b", + "author/mod_b: Dependency cycle detected: author/mod_b -> author/mod_c -> author/mod_b" + } + + +def test_update(): + """ + Test updating a mod will update mods that depend on it + """ + support = GameSupport() + support.add(make_game("game1", ["default"])) + game2 = support.add(make_game("game2", ["core"])) + lib = support.add(make_mod("lib", ["lib"], [])) + modB = support.add(make_mod("mod_b", ["mod_b"], ["default"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b", "lib"])) + support.on_update(modA) + + assert not support.has_errors + + assert modA.is_confirmed + assert modA.detected_supported_games == {"game1"} + + assert modB.is_confirmed + assert modB.detected_supported_games == {"game1"} + + assert lib.is_confirmed + assert len(lib.detected_supported_games) == 0 + + game2.provides.add("mod_b") + support.on_update(game2) + + assert not support.has_errors + + assert modA.is_confirmed + assert modA.detected_supported_games == {"game1", "game2"} + + assert modB.is_confirmed + assert modB.detected_supported_games == {"game1"} + + assert lib.is_confirmed + assert len(lib.detected_supported_games) == 0 + + +def test_update_new_mod(): + """ + Test that adding a mod will update mods that depend on the modname + """ + support = GameSupport() + support.add(make_game("game1", ["default"])) + support.add(make_game("game2", ["core", "mod_b"])) + lib = support.add(make_mod("lib", ["lib"], [])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b", "lib"])) + support.on_update(modA) + + assert not support.has_errors + + assert modA.is_confirmed + assert modA.detected_supported_games == {"game2"} + + assert lib.is_confirmed + assert len(lib.detected_supported_games) == 0 + + modB = support.add(make_mod("mod_b", ["mod_b"], ["default"])) + support.on_update(modB) + + assert not support.has_errors + + assert modA.is_confirmed + assert modA.detected_supported_games == {"game1", "game2"} + + assert modB.is_confirmed + assert modB.detected_supported_games == {"game1"} + + assert lib.is_confirmed + assert len(lib.detected_supported_games) == 0 + + +def test_update_cycle(): + """ + Test that updating a package with a cycle depending on it doesn't break + """ + support = GameSupport() + game1 = support.add(make_game("game1", ["default"])) + modB = support.add(make_mod("mod_b", ["mod_b"], ["default"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b"])) + support.on_update(modA) + + assert not support.has_errors + + assert modA.is_confirmed + assert modA.detected_supported_games == {"game1"} + + assert modB.is_confirmed + assert modB.detected_supported_games == {"game1"} + + support.add(make_mod("mod_c", ["mod_c"], ["mod_a"])) + modA.depends.add("mod_c") + support.on_update(game1) + + assert support.all_errors == { + "author/mod_c: Dependency cycle detected: author/mod_a -> author/mod_c -> author/mod_a", + "author/mod_a: Dependency cycle detected: author/mod_a -> author/mod_c -> author/mod_a", + "author/mod_a: Dependency cycle detected: author/mod_c -> author/mod_a -> author/mod_c", + "author/mod_a: Unable to fulfill dependency mod_c", + "author/mod_c: Dependency cycle detected: author/mod_c -> author/mod_a -> author/mod_c", + "author/mod_c: Unable to fulfill dependency mod_a" + } + + +def test_remove(): + support = GameSupport() + support.add(make_game("game1", ["default"])) + support.add(make_game("game2", ["core", "mod_b"])) + support.add(make_mod("lib", ["lib"], [])) + modB = support.add(make_mod("mod_b", ["mod_b"], ["default"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b", "lib"])) + support.on_update(modA) + + assert not support.has_errors + + assert modA.is_confirmed + assert len(modA.detected_supported_games) == 2 + assert "game1" in modA.detected_supported_games + assert "game2" in modA.detected_supported_games + + assert modB.is_confirmed + assert len(modB.detected_supported_games) == 1 + assert "game1" in modB.detected_supported_games + + support.on_remove(modB) + + assert not support.has_errors + + assert modA.is_confirmed + assert len(modA.detected_supported_games) == 1 + assert "game2" in modA.detected_supported_games + + +def test_propagates_user_unsupported_games(): + support = GameSupport() + support.add(make_game("game1", ["default"])) + support.add(make_game("game2", ["default"])) + modB = support.add(make_mod("mod_b", ["mod_b"], ["default"])) + modB.user_supported_games.add("game1") + modB.user_unsupported_games.add("game2") + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b"])) + support.on_update(modA) + + assert not support.has_errors + + assert modA.is_confirmed + assert modA.detected_supported_games == {"game1"} + assert modA.supported_games == {"game1"} + + assert modB.is_confirmed + assert modB.detected_supported_games == {"game1"} + assert modB.supported_games == {"game1"} + + +def test_propagates_user_supported_games(): + support = GameSupport() + support.add(make_game("game1", ["default"])) + support.add(make_game("game2", ["default"])) + modB = support.add(make_mod("mod_b", ["mod_b"], [])) + modB.user_supported_games.add("game1") + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b"])) + support.on_update(modA) + + assert not support.has_errors + + assert modA.is_confirmed + assert modA.detected_supported_games == {"game1"} + + assert modB.is_confirmed + assert len(modB.detected_supported_games) == 0 + + +def test_validate_inconsistent_user_supported_game(): + support = GameSupport() + support.add(make_game("game1", ["default"])) + support.add(make_game("game2", ["core"])) + support.add(make_mod("mod_b", ["mod_b"], ["core"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b"])) + modA.user_supported_games.add("game1") + support.on_update(modA) + + assert support.all_errors == { + "author/mod_a: `game1` is specified in supported_games but it is impossible to run mod_a in that game. " + "Its dependencies can only be fulfilled in `game2`. " + "Check your hard dependencies.", + } + + +def test_validate_inconsistent_supports_all(): + support = GameSupport() + support.add(make_game("game1", ["default"])) + support.add(make_game("game2", ["core"])) + support.add(make_mod("mod_b", ["mod_b"], ["core"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b"])) + modA.supports_all_games = True + support.on_update(modA) + + assert support.all_errors == { + "author/mod_a: This package cannot support all games as some dependencies require specific game(s): `game2`", + } + + +def test_enable_detection(): + support = GameSupport() + support.add(make_game("game1", ["default"])) + support.add(make_game("game2", ["default"])) + modB = support.add(make_mod("mod_b", ["mod_b"], ["default"])) + modB.user_supported_games.add("game1") + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b"])) + support.on_update(modA) + + assert not support.has_errors + + assert modA.is_confirmed + assert modA.detected_supported_games == {"game1", "game2"} + assert modA.supported_games == {"game1", "game2"} + + assert modB.is_confirmed + assert modB.detected_supported_games == {"game1", "game2"} + assert modB.supported_games == {"game1", "game2"} + + +def test_disable_detection(): + support = GameSupport() + support.add(make_game("game1", ["default"])) + support.add(make_game("game2", ["default"])) + modB = support.add(make_mod("mod_b", ["mod_b"], ["default"])) + modB.user_supported_games.add("game1") + modB.detection_disabled = True + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b"])) + support.on_update(modA) + + assert not support.has_errors + + assert modA.is_confirmed + assert modA.detected_supported_games == {"game1"} + assert modA.supported_games == {"game1"} + + assert modB.is_confirmed + assert modB.detected_supported_games == {"game1", "game2"} + assert modB.supported_games == {"game1"} + + +def test_first_run(): + support = GameSupport() + support.add(make_game("game1", ["default"])) + support.add(make_game("game2", ["core", "mod_b"])) + lib = support.add(make_mod("lib", ["lib"], [])) + modB = support.add(make_mod("mod_b", ["mod_b"], ["default"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["mod_b", "lib"])) + modF = support.add(make_mod("mod_f", ["mod_f"], [])) + + assert not support.all_confirmed + support.on_first_run() + assert support.all_confirmed + + assert not support.has_errors + assert modA.detected_supported_games == {"game1", "game2"} + assert modB.detected_supported_games == {"game1"} + assert len(lib.detected_supported_games) == 0 + assert len(modF.detected_supported_games) == 0 + + +def test_ignores_mtg_in_violating_games(): + support = GameSupport() + support.add(make_game("minetest_game", ["default"])) + support.add(make_game("tutorial", ["default", "tutorial"])) + modA = support.add(make_mod("mod_a", ["mod_a"], ["default"])) + modB = support.add(make_mod("mod_b", ["mod_b"], ["tutorial"])) + support.on_first_run() + + assert not support.has_errors + assert modA.detected_supported_games == {"minetest_game"} + assert modB.detected_supported_games == {"tutorial"} diff --git a/app/utils/models.py b/app/utils/models.py index 66626f64..2e7bc404 100644 --- a/app/utils/models.py +++ b/app/utils/models.py @@ -73,23 +73,29 @@ def is_package_page(f): return decorated_function -def add_notification(target, causer: User, type: NotificationType, title: str, url: str, package: Package = None): +def add_notification(target, causer: User, type: NotificationType, title: str, url: str, + package: Package = None, session: sqlalchemy.orm.Session = None): + if session is None: + session = db.session + try: iter(target) for x in target: - add_notification(x, causer, type, title, url, package) + add_notification(x, causer, type, title, url, package, session) return except TypeError: pass if target.rank.at_least(UserRank.NEW_MEMBER) and target != causer: - Notification.query.filter_by(user=target, causer=causer, type=type, title=title, url=url, package=package).delete() + session.query(Notification) \ + .filter_by(user=target, causer=causer, type=type, title=title, url=url, package=package) \ + .delete() notif = Notification(target, causer, type, title, url, package) - db.session.add(notif) + session.add(notif) def add_audit_log(severity: AuditSeverity, causer: User, title: str, url: typing.Optional[str], - package: Package = None, description: str = None): + package: Package = None, description: str = None): entry = AuditLogEntry(causer, severity, title, url, package, description) db.session.add(entry) @@ -114,7 +120,10 @@ def add_system_audit_log(severity: AuditSeverity, title: str, url: str, package= return add_audit_log(severity, get_system_user(), title, url, package, description) -def post_bot_message(package: Package, title: str, message: str): +def post_bot_message(package: Package, title: str, message: str, session=None): + if session is None: + session = db.session + system_user = get_system_user() thread = package.threads.filter_by(author=system_user).first() @@ -125,16 +134,16 @@ def post_bot_message(package: Package, title: str, message: str): thread.author = system_user thread.private = True thread.watchers.extend(package.maintainers) - db.session.add(thread) - db.session.flush() + session.add(thread) + session.flush() reply = ThreadReply() - reply.thread = thread - reply.author = system_user + reply.thread = thread + reply.author = system_user reply.comment = "**{}**\n\n{}\n\nThis is an automated message, but you can reply if you need help".format(title, message) - db.session.add(reply) + session.add(reply) - add_notification(thread.watchers, system_user, NotificationType.BOT, title, thread.get_view_url(), thread.package) + add_notification(thread.watchers, system_user, NotificationType.BOT, title, thread.get_view_url(), thread.package, session) thread.replies.append(reply)