diff --git a/.travis.yml b/.travis.yml index f8b15cdc..18fff5bc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,12 @@ language: php php: - - 5.6 - 7 - 7.1 + - 7.2 - nightly matrix: - include: - - php: "hhvm" - env: - - NO_COVERAGE=1 - - BZION_HHVM=1 allow_failures: - - php: hhvm - php: nightly fast_finish: true diff --git a/app/Resource/symfony_dev.yml b/app/Resource/symfony_dev.yml index 96660246..9147c1d3 100644 --- a/app/Resource/symfony_dev.yml +++ b/app/Resource/symfony_dev.yml @@ -5,8 +5,13 @@ services: bzion.command.log_command: class: BZIon\Command\LogCommand tags: - - { name: console.command } + bzion.twig_debug_extension: + class: Ajgl\Twig\Extension\BreakpointExtension + public: false + tags: + - { name: twig.extension } + framework: router: resource: "%kernel.root_dir%/Resource/routes_dev.yml" diff --git a/composer.json b/composer.json index a38ed648..d9ab02b8 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "license": "GPL-3.0-only", "require": { - "php": ">=5.6", + "php": ">=7.0.9", "ext-PDO": "*", "ext-gd": "*", "ext-pdo_mysql": "*", @@ -18,6 +18,7 @@ "maciejczyzewski/bottomline": "dev-master", "nelmio/api-doc-bundle": "~2.7", "nesbot/carbon": "~1.14", + "pecee/pixie": "~4.2.5", "robmorgan/phinx": "~0.8", "sensio/framework-extra-bundle": "~3.0", "swiftmailer/swiftmailer": ">=5.2.0", @@ -29,17 +30,18 @@ "yzalis/identicon": "~1.2.0" }, "require-dev": { + "ajgl/breakpoint-twig-extension": "^0.3.1", "behat/behat": "~3.0", "behat/mink": "~1.6", - "behat/mink-extension": "~2.0", "behat/mink-browserkit-driver": "~1.2", + "behat/mink-extension": "~2.0", "behat/symfony2-extension": "~2.1.1", "composer/composer": "~1.5", + "fzaninotto/faker": "^1.7", "jdorn/sql-formatter": "~1.2", "phpunit/phpunit": "~5.7", "sensio/distribution-bundle": "~3.0", - "sensiolabs/security-checker": "~4.1", - "fzaninotto/faker": "^1.7" + "sensiolabs/security-checker": "~4.1" }, "scripts": { @@ -110,7 +112,7 @@ }, "config": { "platform": { - "php": "5.6.0" + "php": "7.0.9" }, "sort-packages": true }, diff --git a/composer.lock b/composer.lock index 833669ba..2e8520ae 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "d7036413c2f94fccc3ef363f26c60202", + "content-hash": "07c65a4ec4714e941204979b2a79f13a", "packages": [ { "name": "cboden/ratchet", @@ -1175,12 +1175,12 @@ "source": { "type": "git", "url": "https://github.com/maciejczyzewski/bottomline.git", - "reference": "b20a5856870e2d32984dcb6c617522ca72afd559" + "reference": "1d1fa0d851f39310ccb2df55256aa4b7585adbcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maciejczyzewski/bottomline/zipball/b20a5856870e2d32984dcb6c617522ca72afd559", - "reference": "b20a5856870e2d32984dcb6c617522ca72afd559", + "url": "https://api.github.com/repos/maciejczyzewski/bottomline/zipball/1d1fa0d851f39310ccb2df55256aa4b7585adbcd", + "reference": "1d1fa0d851f39310ccb2df55256aa4b7585adbcd", "shasum": "" }, "require": { @@ -1217,7 +1217,7 @@ "library", "utility" ], - "time": "2017-11-27T15:08:12+00:00" + "time": "2018-02-10 09:58:57" }, { "name": "michelf/php-markdown", @@ -1534,6 +1534,70 @@ ], "time": "2017-09-27T21:40:39+00:00" }, + { + "name": "pecee/pixie", + "version": "4.2.5", + "source": { + "type": "git", + "url": "https://github.com/skipperbent/pecee-pixie.git", + "reference": "b6744599f8514eb4bb6d521155cb152242b5bd93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/skipperbent/pecee-pixie/zipball/b6744599f8514eb4bb6d521155cb152242b5bd93", + "reference": "b6744599f8514eb4bb6d521155cb152242b5bd93", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "mockery/mockery": "^1", + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Pecee\\Pixie\\": "src/Pecee/Pixie/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Muhammad Usman", + "email": "hi@usman.it", + "role": "Developer" + }, + { + "name": "Pavel Puchkin", + "email": "i@neoascetic.me", + "role": "Developer" + }, + { + "name": "Simon Sessingø", + "email": "simon.sessingoe@gmail.com", + "role": "Developer" + } + ], + "description": "Lightweight, fast query-builder for PHP based on Laravel Eloquent but with less overhead.", + "homepage": "https://github.com/skipperbent/pecee-pixie", + "keywords": [ + "database", + "eloquent", + "mysql", + "pecee", + "pixie", + "postgresql", + "query builder", + "querybuilder", + "sql", + "sqlite" + ], + "time": "2018-01-16T23:53:01+00:00" + }, { "name": "phpcollection/phpcollection", "version": "0.5.0", @@ -2896,6 +2960,66 @@ } ], "packages-dev": [ + { + "name": "ajgl/breakpoint-twig-extension", + "version": "0.3.1", + "source": { + "type": "git", + "url": "https://github.com/ajgarlag/AjglBreakpointTwigExtension.git", + "reference": "360ec6351ad7e1968ee78abb31430046c2e04fc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ajgarlag/AjglBreakpointTwigExtension/zipball/360ec6351ad7e1968ee78abb31430046c2e04fc5", + "reference": "360ec6351ad7e1968ee78abb31430046c2e04fc5", + "shasum": "" + }, + "require": { + "php": ">=5.6", + "twig/twig": "^1.14|^2.0" + }, + "require-dev": { + "phpunit/phpunit": "^5", + "symfony/framework-bundle": "^2.7|^3.2", + "symfony/twig-bundle": "^2.7|^3.2" + }, + "suggest": { + "ext-xdebug": "The Xdebug extension is required for the breakpoint to work", + "symfony/framework-bundle": "The framework bundle to integrate the extension into Symfony", + "symfony/twig-bundle": "The twig bundle to integrate the extension into Symfony" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Ajgl\\Twig\\Extension\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio J. GarcĂa Lagar", + "email": "aj@garcialagar.es", + "homepage": "http://aj.garcialagar.es", + "role": "developer" + } + ], + "description": "Twig extension to set breakpoints", + "homepage": "https://github.com/ajgarlag/AjglBreakpointTwigExtension", + "keywords": [ + "Xdebug", + "breakpoint", + "twig" + ], + "time": "2017-11-20T13:04:11+00:00" + }, { "name": "behat/behat", "version": "v3.4.3", @@ -5333,13 +5457,13 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=5.6", + "php": ">=7.0.9", "ext-pdo": "*", "ext-gd": "*", "ext-pdo_mysql": "*" }, "platform-dev": [], "platform-overrides": { - "php": "5.6.0" + "php": "7.0.9" } } diff --git a/controllers/BanController.php b/controllers/BanController.php index 5efa8b47..ee5bf0f8 100644 --- a/controllers/BanController.php +++ b/controllers/BanController.php @@ -15,11 +15,13 @@ public function listAction(Request $request) $currentPage = $this->getCurrentPage(); $qb = $this->getQueryBuilder() - ->sortBy('updated')->reverse() - ->limit(16)->fromPage($currentPage); + ->orderBy('updated', 'DESC') + ->limit(16) + ->fromPage($currentPage) + ; return array( - 'bans' => $qb->getModels(), + 'bans' => $qb->getModels(true), 'currentPage' => $currentPage, 'totalPages' => $qb->countPages() ); diff --git a/controllers/InvitationController.php b/controllers/InvitationController.php index 9f76f0a7..95055394 100644 --- a/controllers/InvitationController.php +++ b/controllers/InvitationController.php @@ -3,11 +3,13 @@ use BZIon\Event\Events; use BZIon\Event\TeamInviteEvent; use BZIon\Event\TeamJoinEvent; +use BZIon\Form\Creator\InvitationFormCreator; use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; class InvitationController extends CRUDController { - public function acceptAction(Invitation $invitation, Player $me) + public function acceptAction(Player $me, Invitation $invitation) { if (!$me->isTeamless()) { throw new ForbiddenException("You can't join a new team until you leave your current one."); @@ -22,7 +24,7 @@ public function acceptAction(Invitation $invitation, Player $me) } if ($invitation->getTeam()->isDeleted()) { - $invitation->updateExpiration(); + $invitation->setExpired(); throw new ForbiddenException("This invitation is for a team which has been deleted."); } @@ -32,7 +34,8 @@ public function acceptAction(Invitation $invitation, Player $me) return $this->showConfirmationForm(function () use ($invitation, $team, $me) { $team->addMember($me->getId()); - $invitation->updateExpiration(); + $invitation->setStatus(Invitation::STATUS_ACCEPTED); + $invitation->setExpired(); Service::getDispatcher()->dispatch(Events::TEAM_JOIN, new TeamJoinEvent($team, $me)); return new RedirectResponse($team->getUrl()); @@ -40,22 +43,39 @@ public function acceptAction(Invitation $invitation, Player $me) "You are now a member of {$team->getName()}"); } - public function inviteAction(Team $team, Player $player, Player $me) + public function inviteAction(Player $me, Team $team, Player $player) { if (!$me->canEdit($team)) { throw new ForbiddenException("You are not allowed to invite a player to that team!"); - } elseif ($team->isMember($player->getId())) { - throw new ForbiddenException("The specified player is already a member of that team."); - } elseif (Invitation::hasOpenInvitation($player->getId(), $team->getId())) { - throw new ForbiddenException("This player has already been invited to join the team."); } - return $this->showConfirmationForm(function () use ($team, $player, $me) { - $invite = Invitation::sendInvite($player->getId(), $team->getId(), $me->getId()); - Service::getDispatcher()->dispatch(Events::TEAM_INVITE, new TeamInviteEvent($invite)); + $creator = new InvitationFormCreator($team, $me, $this); + $form = $creator->create()->handleRequest(self::getRequest()); - return new RedirectResponse($team->getUrl()); - }, "Are you sure you want to invite {$player->getEscapedUsername()} to {$team->getEscapedName()}?", - "Player {$player->getUsername()} has been invited to {$team->getName()}"); + if ($form->isSubmitted()) { + $this->validate($form); + + if ($form->isValid()) { + $clickedButton = $form->getClickedButton()->getName(); + + if ($clickedButton === 'submit') { + $invitation = $creator->enter($form); + + self::getFlashBag()->add('success', sprintf('"%s" has been invited to "%s."', $invitation->getInvitedPlayer()->getName(), $team->getName())); + + return $this->redirectTo($team); + } + + return (new RedirectResponse($this->getPreviousURL())); + } + } else { + $form->get('invited_player')->setData($player); + } + + return [ + 'form' => $form->createView(), + 'team' => $team, + 'player' => $player, + ]; } } diff --git a/controllers/MapController.php b/controllers/MapController.php index a26e0c10..95c3d708 100644 --- a/controllers/MapController.php +++ b/controllers/MapController.php @@ -5,17 +5,18 @@ class MapController extends CRUDController public function listAction(Map $map = null) { if ($map === null) { - $qb = $this->getQueryBuilder(); - - $maps = $qb->sortBy('name') - ->getModels(); + $maps = $this->getQueryBuilder() + ->active() + ->orderBy('name') + ->getModels() + ; } else { - $maps = array($map); + $maps = [$map]; } - return array( - "maps" => $maps - ); + return [ + 'maps' => $maps + ]; } public function createAction(Player $me) diff --git a/controllers/NewsController.php b/controllers/NewsController.php index 92d15507..e9e7a566 100644 --- a/controllers/NewsController.php +++ b/controllers/NewsController.php @@ -4,29 +4,44 @@ class NewsController extends CRUDController { - public function showAction(News $article) + public function showAction(Player $me, News $article) { - return array("article" => $article, "categories" => $this->getCategories()); + if ($article->isDraft() && (!$me->isValid() || !$me->hasPermission(News::EDIT_PERMISSION))) { + throw new ForbiddenException('You do not have permission to view draft posts.'); + } + + return [ + 'article' => $article, + 'categories' => $this->getCategories(), + ]; } - public function listAction(Request $request, NewsCategory $category = null) + public function listAction(Request $request, Player $me, NewsCategory $category = null) { + $currentPage = $this->getCurrentPage(); $qb = $this->getQueryBuilder(); - $currentPage = $this->getCurrentPage(); + $news = $qb + ->orderBy('created', 'DESC') + ->limit(5) + ->fromPage($currentPage) + ; + + if ($category !== null) { + $news->where('category', '=', $category->getId()); + } + + if (!$me->isValid() || !$me->hasPermission(News::CREATE_PERMISSION)) { + $news->whereNot('is_draft', '=', true); + } - $news = $qb->sortBy('created')->reverse() - ->where('category')->is($category) - ->limit(5)->fromPage($currentPage) - ->getModels(); - - return array( - "news" => $news, - "categories" => $this->getCategories(), - "category" => $category, - "currentPage" => $currentPage, - "totalPages" => $qb->countPages() - ); + return [ + 'news' => $news->getModels(true), + 'categories' => $this->getCategories(), + 'category' => $category, + 'currentPage' => $currentPage, + 'totalPages' => $qb->countPages(), + ]; } public function createAction(Player $me) @@ -46,8 +61,9 @@ public function deleteAction(Player $me, News $article) private function getCategories() { - return $this->getQueryBuilder('NewsCategory') - ->sortBy('name') - ->getModels(); + return NewsCategory::getQueryBuilder() + ->orderBy('name') + ->getModels(true) + ; } } diff --git a/controllers/PageController.php b/controllers/PageController.php index 930f206c..87db03e6 100644 --- a/controllers/PageController.php +++ b/controllers/PageController.php @@ -18,7 +18,9 @@ public function showDefaultAction() public function showAction(Page $page) { - return array("page" => $page); + return [ + 'page' => $page + ]; } public function createAction(Player $me, Request $request) diff --git a/controllers/PlayerController.php b/controllers/PlayerController.php index f1f8e9e4..55a499ea 100644 --- a/controllers/PlayerController.php +++ b/controllers/PlayerController.php @@ -50,7 +50,7 @@ public function showAction(Player $player, Player $me, Request $request) } $bans = Ban::getQueryBuilder() - ->where('player')->is($player->getId()) + ->where('player', '=', $player->getId()) ->getModels($fast = true) ; @@ -84,12 +84,13 @@ public function listAction(Request $request, Player $me, Team $team = null) Country::getQueryBuilder()->addToCache(); if ($team) { - $query->where('team')->is($team); + $query->where('team', '=', $team); } else { // Add all teams to the cache - $this->getQueryBuilder('Team') - ->where('members')->greaterThan(0) - ->addToCache(); + Team::getQueryBuilder() + ->where('members', '>', 0) + ->addToCache() + ; } if ($request->query->has('exceptMe')) { @@ -103,24 +104,18 @@ public function listAction(Request $request, Player $me, Team $team = null) $query ->active() ->withMatchActivity() - ->sortBy('name') + ->orderBy('username') ; if (!$request->query->get('showAll')) { - $query->having('activity')->greaterThan(0); + $query->having('activity', '>', 0); } if ($sortBy || $sortOrder) { - $sortBy = $sortBy ? $sortBy : 'callsign'; - $sortOrder = $sortOrder ? $sortOrder : 'ASC'; + $sortBy = $sortBy ? 'activity' : 'callsign'; + $sortOrder = $sortOrder ? 'DESC' : 'ASC'; - if ($sortBy === 'activity') { - $query->sortBy($sortBy); - } - - if ($sortOrder == 'DESC') { - $query->reverse(); - } + $query->orderBy($sortBy, $sortOrder); } $players = $query->getModels($fast = true); diff --git a/controllers/SeasonController.php b/controllers/SeasonController.php index 05404a5f..127de17b 100644 --- a/controllers/SeasonController.php +++ b/controllers/SeasonController.php @@ -1,5 +1,6 @@ parseSeason($period, $year); - // Because this query can't be created efficiently using our QueryBuilder, let's do things manually - $db = Database::getInstance(); - $seasonQuery = sprintf(" - SELECT %s, e.elo_new AS elo FROM players p - INNER JOIN player_elo e ON e.user_id = p.id - INNER JOIN ( - SELECT - user_id, - MAX(match_id) AS last_match - FROM - player_elo - WHERE - season_period = ? AND season_year = ? - GROUP BY - user_id - ) i ON i.user_id = p.id AND i.last_match = e.match_id - WHERE p.status = 'active' - ORDER BY elo DESC, p.username ASC LIMIT 10; - ", Player::getEagerColumns('p')); - $results = $db->query($seasonQuery, [$period, $year]); - $players_w_elos = Player::createFromDatabaseResults($results); + // Subquery to get the the most recent Elo for players in the specified season + $playerEloQuery = $qb->table('player_elo'); + $playerEloQuery + ->select([ + 'user_id', + $qb->raw('MAX(match_id) AS last_match') + ]) + ->where('season_period', '=', $period) + ->where('season_year', '=', $year) + ->groupBy('user_id') + ; + + // Get Player models of the top 10 players + $playersWithElos = Player::getQueryBuilder() + ->select( + $qb->raw('player_elo.elo_new AS elo') + ) + ->innerJoin('player_elo', 'player_elo.user_id', '=', 'players.id') + ->innerJoin( + $qb->subQuery($playerEloQuery, 'i'), + function ($table) { + /** @var JoinBuilder $table */ + $table->on('i.user_id', '=', 'players.id'); + $table->on('i.last_match', '=', 'player_elo.match_id'); + } + ) + ->active() + ->orderBy('elo', 'DESC') + ->orderBy('players.username', 'ASC') + ->limit(10) + ->getModels() + ; + + // + // Get total amount of matches and their classification (fm or official) + // $seasonRange = Season::getCurrentSeasonRange($period); $matchQuery = Match::getQueryBuilder(); @@ -54,62 +71,72 @@ public function showAction(Request $request, $period, $year) $fmCount = $fmQuery->where('type')->equals(Match::FUN)->count(); $offiCount = $offiQuery->where('type')->equals(Match::OFFICIAL)->count(); - Map::getQueryBuilder()->addToCache(); - $mapQuery = ' - SELECT - map AS map_id, - COUNT(*) AS match_count - FROM - matches - WHERE - timestamp >= ? AND timestamp <= ? AND map IS NOT NULL - GROUP BY - map - HAVING - match_count > 0 - ORDER BY - match_count DESC - '; - $results = $db->query($mapQuery, [ - $seasonRange->getStartOfRange($year), - $seasonRange->getEndOfRange($year), - ]); - - $mapIDs = array_column($results, 'map_id'); + // + // Get map statistics; such as how many matches occurred in on each map + // + + // Cache all of our non-deleted maps + Map::getQueryBuilder() + ->active() + ->addToCache() + ; + + $mapMatchCounts = QueryBuilderFlex::createForTable(Match::TABLE) + ->where('timestamp', '>=', $seasonRange->getStartOfRange($year)) + ->where('timestamp', '<=', $seasonRange->getEndOfRange($year)) + ->whereNotNull('map') + ->groupBy('map_id') + ->having('match_count', '>', 0) + ->orderBy('match_count', 'DESC') + ->getArray([ + 'map' => 'map_id', + $qb->raw('COUNT(*) AS match_count'), + ]) + ; + + $mapIDs = array_column($mapMatchCounts, 'map_id'); $maps = Map::arrayIdToModel($mapIDs); - $mapCount = array_combine($mapIDs, $results); - - $matchCount = " - SELECT - p.user_id, - SUM(m.match_type = ?) AS match_count - FROM - match_participation p - INNER JOIN - matches m ON m.id = p.match_id - WHERE - m.timestamp >= ? AND m.timestamp < ? - GROUP BY - p.user_id - ORDER BY - match_count DESC - LIMIT 10 - "; - $fmResults = $db->query($matchCount, [ - 'fm', - $seasonRange->getStartOfRange($year), - $seasonRange->getEndOfRange($year), - ]); - $offiResults = $db->query($matchCount, [ - 'official', - $seasonRange->getStartOfRange($year), - $seasonRange->getEndOfRange($year), - ]); + $mapCount = array_combine($mapIDs, $mapMatchCounts); + + // + // Get match count totals for players; how many official or fun matches a player participated in + // + + $playerMatchTotals = QueryBuilderFlex::createForTable('match_participation')->alias('p'); + $playerMatchTotals + ->select([ + 'p.user_id', + $qb->raw('COUNT(matches.id) AS match_count') + ]) + ->innerJoin('matches', 'matches.id', '=', 'p.match_id') + ->where('matches.timestamp', '>=', $seasonRange->getStartOfRange($year)) + ->where('matches.timestamp', '<', $seasonRange->getEndOfRange($year)) + ->groupBy('p.user_id') + ->orderBy('match_count', 'DESC') + ->limit(10) + ; + + $fmResults = (clone $playerMatchTotals)->where('matches.match_type', '=', Match::FUN)->get(); + $offiResults = (clone $playerMatchTotals)->where('matches.match_type', '=', Match::OFFICIAL)->get(); + + // Get the unique player IDs from the above queries to cache them so we don't have individual queries for each + // player. + $matchTotalsPlayerIDs = array_unique( + array_merge( + array_column($fmResults, 'user_id'), + array_column($offiResults, 'user_id') + ) + ); + + Player::getQueryBuilder() + ->whereIn('id', $matchTotalsPlayerIDs) + ->addToCache() + ; return [ 'season' => ucfirst($period), 'year' => $year, - 'players' => $players_w_elos, + 'players' => $playersWithElos, 'fmCount' => $fmCount, 'offiCount' => $offiCount, 'maps' => $maps, diff --git a/controllers/ServerController.php b/controllers/ServerController.php index 34080390..857485a3 100644 --- a/controllers/ServerController.php +++ b/controllers/ServerController.php @@ -10,7 +10,8 @@ public function listAction() { $servers = $this ->getQueryBuilder() - ->sortBy('name') + ->active() + ->orderBy('name') ->getModels() ; diff --git a/controllers/TeamController.php b/controllers/TeamController.php index 780b33cf..39f11a46 100644 --- a/controllers/TeamController.php +++ b/controllers/TeamController.php @@ -34,23 +34,31 @@ public function showAction(Team $team) ]; } + /** + * @param Request $request + * + * @throws \Pecee\Pixie\Exception + * @throws Exception + * + * @return array + */ public function listAction(Request $request) { $teamQB = Team::getQueryBuilder() ->active() - ->sortBy('elo')->reverse() + ->orderBy('elo', 'DESC') ; // Cache team captains so we don't fetch each manually $captains = $teamQB->getArray('leader'); $captIDs = array_column($captains, 'leader'); Player::getQueryBuilder() - ->where('id')->isOneOf($captIDs) + ->whereIn('id', $captIDs) ->addToCache() ; return [ - 'teams' => $teamQB->withMatchActivity()->getModels($fast = true), + 'teams' => $teamQB->withMatchActivity()->getModels(), 'showAll' => (bool)$request->get('showAll', false), ]; } diff --git a/controllers/VisitLogController.php b/controllers/VisitLogController.php index c6c69224..7e083bc7 100644 --- a/controllers/VisitLogController.php +++ b/controllers/VisitLogController.php @@ -13,8 +13,7 @@ public function setup() public function listAction(Request $request) { - /** @var VisitQueryBuilder $qb */ - $qb = $this->getQueryBuilder(); + $qb = Visit::getQueryBuilder(); $currentPage = $this->getCurrentPage(); @@ -22,20 +21,17 @@ public function listAction(Request $request) $qb->search($request->query->get('search')); } - $visits = $qb->sortBy('timestamp')->reverse() + $visits = $qb + ->orderBy('timestamp', 'DESC') ->limit(30)->fromPage($currentPage) - ->getModels($fast = true); - - return array( - "visits" => $visits, - "currentPage" => $currentPage, - "totalPages" => $qb->countPages(), - "search" => $request->query->get('search') - ); - } - - public static function getQueryBuilder($type = "Visit") - { - return $type::getQueryBuilder(); + ->getModels() + ; + + return [ + 'visits' => $visits, + 'currentPage' => $currentPage, + 'totalPages' => $qb->countPages(), + 'search' => $request->query->get('search') + ]; } } diff --git a/migrations/20180201224448_bans_status_column_conversion.php b/migrations/20180201224448_bans_status_column_conversion.php new file mode 100644 index 00000000..797a1fe8 --- /dev/null +++ b/migrations/20180201224448_bans_status_column_conversion.php @@ -0,0 +1,50 @@ +table('bans'); + $bansTable + ->addColumn('is_deleted', 'boolean', [ + 'after' => 'is_soft_ban', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not the ban has been deleted', + ]) + ->update() + ; + + $this->query("UPDATE bans SET is_deleted = 1 WHERE status = 'deleted';"); + + $bansTable + ->removeColumn('status') + ->update() + ; + } + + public function down() + { + $bansTable = $this->table('bans'); + $bansTable + ->addColumn('status', 'set', [ + 'values' => ['public', 'hidden', 'deleted'], + 'after' => 'is_soft_ban', + 'null' => false, + 'default' => 'public', + 'comment' => 'The status of the ban element', + ]) + ->update() + ; + + $this->query("UPDATE bans SET status = 'deleted' WHERE is_deleted = true;"); + + $bansTable + ->removeColumn('is_deleted') + ->update() + ; + } +} diff --git a/migrations/20180202080131_allow_country_deletion.php b/migrations/20180202080131_allow_country_deletion.php new file mode 100644 index 00000000..5559573e --- /dev/null +++ b/migrations/20180202080131_allow_country_deletion.php @@ -0,0 +1,21 @@ +table('countries'); + $countriesTable + ->addColumn('is_deleted', 'boolean', [ + 'after' => 'name', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not the country has been deleted', + ]) + ->update() + ; + } +} diff --git a/migrations/20180202213952_news_status_column_conversion.php b/migrations/20180202213952_news_status_column_conversion.php new file mode 100644 index 00000000..7c967d15 --- /dev/null +++ b/migrations/20180202213952_news_status_column_conversion.php @@ -0,0 +1,67 @@ +table('news'); + $newsTable + ->addColumn('is_draft', 'boolean', [ + 'after' => 'editor', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not the news article is a draft', + ]) + ->addColumn('is_deleted', 'boolean', [ + 'after' => 'is_draft', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not the news article has been soft deleted', + ]) + ->update() + ; + + $this->query("UPDATE news SET is_draft = 1 WHERE status = 'revision' OR status ='draft';"); + $this->query("UPDATE news SET is_deleted = 1 WHERE status = 'deleted' OR status = 'disabled';"); + + $newsTable + ->removeColumn('parent_id') + ->removeColumn('status') + ->update() + ; + } + + public function down() + { + $newsTable = $this->table('news'); + $newsTable + ->addColumn('parent_id', 'integer', [ + 'after' => 'id', + 'null' => true, + 'default' => null, + 'length' => 11, + 'comment' => 'The ID of the original news post. If this column is set, then it is a revision', + ]) + ->addColumn('status', 'set', [ + 'values' => ['published', 'revision', 'draft', 'disabled', 'deleted'], + 'after' => 'editor', + 'null' => false, + 'default' => 'published', + 'comment' => 'The status of the news element', + ]) + ->update() + ; + + $this->query("UPDATE news SET status = 'draft' WHERE is_draft = 1;"); + $this->query("UPDATE news SET status = 'deleted' WHERE is_deleted = 1;"); + + $newsTable + ->removeColumn('is_draft') + ->removeColumn('is_deleted') + ->update() + ; + } +} diff --git a/migrations/20180206040040_news_category_status_column_conversion.php b/migrations/20180206040040_news_category_status_column_conversion.php new file mode 100644 index 00000000..7de1ad5c --- /dev/null +++ b/migrations/20180206040040_news_category_status_column_conversion.php @@ -0,0 +1,63 @@ +table('news_categories'); + $newsCategoryTable + ->addColumn('is_read_only', 'boolean', [ + 'after' => 'protected', + 'null' => false, + 'default' => false, + 'comment' => 'When set to true, no new articles should be able to use this category' + ]) + ->addColumn('is_deleted', 'boolean', [ + 'after' => 'is_read_only', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not the news category has been soft deleted', + ]) + ->changeColumn('protected', 'boolean', [ + 'default' => false, + 'null' => false, + 'comment' => 'When set to true, prevents the category from being deleted from the UI', + ]) + ->update() + ; + + $this->query("UPDATE news_categories SET is_deleted = 1 WHERE status = 'deleted';"); + + $newsCategoryTable + ->removeColumn('status') + ->renameColumn('protected', 'is_protected') + ->update() + ; + } + + public function down() + { + $newsCategoryTable = $this->table('news_categories'); + $newsCategoryTable + ->addColumn('status', 'set', [ + 'values' => ['enabled', 'disabled', 'deleted'], + 'after' => 'is_deleted', + 'null' => false, + 'default' => 'enabled', + 'comment' => 'The status of the news element', + ]) + ->renameColumn('is_protected', 'protected') + ->update() + ; + + $this->query("UPDATE news_categories SET status = 'deleted' WHERE is_deleted = 1;"); + + $newsCategoryTable + ->removeColumn('is_deleted') + ->removeColumn('is_read_only') + ->update() + ; + } +} diff --git a/migrations/20180208072640_page_status_conversion.php b/migrations/20180208072640_page_status_conversion.php new file mode 100644 index 00000000..b00d942e --- /dev/null +++ b/migrations/20180208072640_page_status_conversion.php @@ -0,0 +1,81 @@ +table('pages'); + $pagesTable + ->addColumn('is_unlisted', 'boolean', [ + 'after' => 'home', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this page should be listed in the secondary navigation', + ]) + ->addColumn('is_draft', 'boolean', [ + 'after' => 'is_unlisted', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not the news article is a draft', + ]) + ->addColumn('is_deleted', 'boolean', [ + 'after' => 'is_draft', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this entry has been soft-deleted', + ]) + ->update() + ; + + $this->query("UPDATE pages SET is_deleted = 1 WHERE status = 'deleted';"); + $this->query("UPDATE pages SET is_unlisted = 1 WHERE status = 'revision';"); + + $pagesTable + ->removeColumn('parent_id') + ->removeColumn('home') + ->removeColumn('status') + ->update() + ; + } + + public function down() + { + $pagesTable = $this->table('pages'); + $pagesTable + ->addColumn('parent_id', 'integer', [ + 'after' => 'id', + 'null' => true, + 'default' => null, + 'length' => 10, + 'comment' => 'The ID of the original page. If this column is set, then it is a revision', + ]) + ->addColumn('home', 'integer', [ + 'after' => 'author', + 'length' => 4, + 'null' => true, + 'default' => null, + 'comment' => '(Deprecated) Whether or not the page is the home page', + ]) + ->addColumn('status', 'set', [ + 'values' => ['live', 'revision', 'disabled', 'deleted'], + 'after' => 'home', + 'null' => false, + 'default' => 'live', + 'comment' => 'The status of this page', + ]) + ->update() + ; + + $this->query("UPDATE pages SET status = 'deleted' WHERE is_deleted = 1"); + $this->query("UPDATE pages SET status = 'revision' WHERE is_unlisted = 1"); + + $pagesTable + ->removeColumn('is_unlisted') + ->removeColumn('is_draft') + ->removeColumn('is_deleted') + ->update() + ; + } +} diff --git a/migrations/20180209070716_make_invitations_better.php b/migrations/20180209070716_make_invitations_better.php new file mode 100644 index 00000000..0fbdd17b --- /dev/null +++ b/migrations/20180209070716_make_invitations_better.php @@ -0,0 +1,48 @@ +table('invitations'); + $invitationsTable + ->addColumn('sent', 'datetime', [ + 'after' => 'team', + 'null' => true, + 'default' => null, + 'comment' => 'When the invitation was sent', + ]) + ->addColumn('status', 'integer', [ + 'after' => 'text', + 'null' => false, + 'default' => 0, + 'signed' => true, + 'length' => 1, + 'comment' => '0: pending; 1: accepted; 2: rejected', + ]) + ->addColumn('is_deleted', 'boolean', [ + 'after' => 'status', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not the invitation has been soft deleted', + ]) + ->update() + ; + + $invitationsTable + ->renameColumn('text', 'message') + ->update() + ; + + $invitationsTable + ->changeColumn('message', 'text', [ + 'null' => true, + 'default' => null, + 'comment' => 'The message sent when inviting a player to a team', + ]) + ->update() + ; + } +} diff --git a/migrations/20180210222728_maps_status_column_conversion.php b/migrations/20180210222728_maps_status_column_conversion.php new file mode 100644 index 00000000..155609b6 --- /dev/null +++ b/migrations/20180210222728_maps_status_column_conversion.php @@ -0,0 +1,56 @@ +table('maps'); + $mapsTable + ->addColumn('is_inactive', 'boolean', [ + 'after' => 'game_mode', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this map has been marked as inactive, meaning a map is no longer in active rotation on servers', + ]) + ->addColumn('is_deleted', 'boolean', [ + 'after' => 'is_inactive', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this map has been soft deleted' + ]) + ->update() + ; + + $this->query("UPDATE maps SET is_inactive = 1 WHERE status = 'hidden' OR status = 'disabled';"); + $this->query("UPDATE maps SET is_deleted = 1 WHERE status = 'deleted';"); + + $mapsTable + ->removeColumn('status') + ->update() + ; + } + + public function down() + { + $mapsTable = $this->table('maps'); + $mapsTable + ->addColumn('status', 'set', [ + 'values' => ['active', 'hidden', 'disabled', 'deleted'], + 'null' => false, + 'default' => 'active', + 'comment' => 'The status of the map', + ]) + ->update() + ; + + $this->query("UPDATE maps SET status = 'hidden' WHERE is_inactive = 1;"); + $this->query("UPDATE maps SET status = 'deleted' WHERE is_deleted = 1;"); + + $mapsTable + ->removeColumn('is_inactive') + ->removeColumn('is_deleted') + ; + } +} diff --git a/migrations/20180211020823_server_status_column_conversation.php b/migrations/20180211020823_server_status_column_conversation.php new file mode 100644 index 00000000..8049178d --- /dev/null +++ b/migrations/20180211020823_server_status_column_conversation.php @@ -0,0 +1,73 @@ +table('servers'); + $serversTable + ->addColumn('is_official_server', 'boolean', [ + 'after' => 'updated', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this server is capable of hosting official matches', + ]) + ->addColumn('is_replay_server', 'boolean', [ + 'after' => 'is_official_server', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this server is dedicated to serving replays of matches', + ]) + ->addColumn('is_inactive', 'boolean', [ + 'after' => 'is_replay_server', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this server is no longer active but still required for historical purposes', + + ]) + ->addColumn('is_deleted', 'boolean', [ + 'after' => 'is_inactive', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this server has been soft deleted', + ]) + ->update() + ; + + // BZiON 0.10.x and below required that you soft deleted servers so they'd no longer appear on the server list. + // For this reason and because Leagues United is the only known installation, we'll be assuming that deleted + // servers were just meant to be marked as "inactive." + $this->query("UPDATE servers SET is_inactive = 1 WHERE status = 'deleted';"); + + $serversTable + ->removeColumn('status') + ->update() + ; + } + + public function down() + { + $serversTable = $this->table('servers'); + $serversTable + ->addColumn('status', 'set', [ + 'values' => ['active', 'disabled', 'deleted'], + 'null' => false, + 'default' => 'active', + 'comment' => 'The status of the server relative to BZiON', + ]) + ->update() + ; + + $this->query("UPDATE servers SET status = 'deleted' WHERE is_inactive = 1;"); + + $serversTable + ->removeColumn('is_official_server') + ->removeColumn('is_replay_server') + ->removeColumn('is_inactive') + ->removeColumn('is_deleted') + ->update() + ; + } +} diff --git a/migrations/20180212044818_team_status_column_conversion.php b/migrations/20180212044818_team_status_column_conversion.php new file mode 100644 index 00000000..e750087c --- /dev/null +++ b/migrations/20180212044818_team_status_column_conversion.php @@ -0,0 +1,57 @@ +table('teams'); + $teamsTable + ->addColumn('is_closed', 'boolean', [ + 'after' => 'members', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this team is closed', + ]) + ->addColumn('is_deleted', 'boolean', [ + 'after' => 'is_closed', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this team has been soft deleted', + ]) + ->update() + ; + + $this->query("UPDATE teams SET is_closed = 1 WHERE status = 'closed';"); + $this->query("UPDATE teams SET is_deleted = 1 WHERE status = 'deleted';"); + + $teamsTable + ->removeColumn('status') + ->update() + ; + } + + public function down() + { + $teamsTable = $this->table('teams'); + $teamsTable + ->addColumn('status', 'set', [ + 'values' => ['open', 'closed', 'disabled', 'deleted'], + 'null' => false, + 'default' => 'open', + 'comment' => 'The status of the team', + ]) + ->update() + ; + + $this->query("UPDATE teams SET status = 'deleted' WHERE is_deleted = 1;"); + $this->query("UPDATE teams SET status = 'closed' WHERE is_closed = 1;"); + + $teamsTable + ->removeColumn('is_closed') + ->removeColumn('is_deleted') + ->update() + ; + } +} diff --git a/migrations/20180214080518_player_status_column_conversion.php b/migrations/20180214080518_player_status_column_conversion.php new file mode 100644 index 00000000..e49c3c7b --- /dev/null +++ b/migrations/20180214080518_player_status_column_conversion.php @@ -0,0 +1,57 @@ +table('players'); + $playersTable + ->addColumn('is_disabled', 'boolean', [ + 'after' => 'last_login', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this account has been disabled by an admin and cannot log in', + ]) + ->addColumn('is_deleted', 'boolean', [ + 'after' => 'is_disabled', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not this player has been soft-deleted', + ]) + ->update() + ; + + $this->query("UPDATE players SET is_deleted = 1 WHERE status = 'deleted';"); + $this->query("UPDATE players SET is_disabled = 1 WHERE status = 'disabled';"); + + $playersTable + ->removeColumn('status') + ->update() + ; + } + + public function down() + { + $playersTable = $this->table('players'); + $playersTable + ->addColumn('status', 'set', [ + 'values' => ['active','disabled','deleted','reported','banned','test'], + 'null' => false, + 'default' => 'active', + 'comment' => 'The status of this player', + ]) + ->update() + ; + + $this->query("UPDATE players SET status = 'deleted' WHERE is_deleted = 1;"); + $this->query("UPDATE players SET status = 'disabled' WHERE is_disabled = 1;"); + + $playersTable + ->removeColumn('is_disabled') + ->removeColumn('is_deleted') + ->update() + ; + } +} diff --git a/models/Ban.php b/models/Ban.php index 09c5a8e1..efca4a82 100644 --- a/models/Ban.php +++ b/models/Ban.php @@ -66,14 +66,10 @@ class Ban extends UrlModel implements NamedModel */ protected $ipAddresses; - /** - * The ban's status - * @var string - */ - protected $status; - const DEFAULT_STATUS = 'public'; + const DELETED_COLUMN = 'is_deleted'; + /** * The name of the database table used for queries */ @@ -97,7 +93,7 @@ protected function assignResult($ban) $this->created = TimeDate::fromMysql($ban['created']); $this->updated = TimeDate::fromMysql($ban['updated']); $this->author = $ban['author']; - $this->status = $ban['status']; + $this->is_deleted = $ban['is_deleted']; } /** @@ -395,17 +391,13 @@ public static function addBan($playerID, $authorID, $expiration, $reason, $serve /** * Get a query builder for news * - * @return QueryBuilder + * @throws Exception + * + * @return QueryBuilderFlex */ public static function getQueryBuilder() { - return new QueryBuilder('Ban', array( - 'columns' => array( - 'player' => 'player', - 'status' => 'status', - 'updated' => 'updated' - ), - )); + return QueryBuilderFlex::createForModel(Ban::class); } /** diff --git a/models/Country.php b/models/Country.php index 3d9614b5..8b3d0c26 100644 --- a/models/Country.php +++ b/models/Country.php @@ -8,26 +8,17 @@ /** * A country - * @package BZiON\Models */ -class Country extends Model +class Country extends Model implements NamedModel { - /** - * The name of the country - * @var string - */ + /** @var string The name of the country */ protected $name; - /** - * The ISO code of the country - * @var string - */ + /** @var string The ISO code of the country */ protected $iso; - /** - * The name of the database table used for queries - */ - const TABLE = "countries"; + const DELETED_COLUMN = 'is_deleted'; + const TABLE = 'countries'; /** * {@inheritdoc} @@ -68,31 +59,6 @@ public function getFlagLiteral() return '
'; } - /** - * Get all the countries in the database - * - * @return Country[] An array of country objects - */ - public static function getCountries() - { - return self::arrayIdToModel(self::fetchIds()); - } - - /** - * Get an associative array with country ISO code as keys and country names - * as values - * - * @return array - */ - public static function getCountriesWithISO() - { - $result = Database::getInstance()->query( - 'SELECT iso, name from ' . static::TABLE - ); - - return array_column($result, 'name', 'iso'); - } - /** * Get the country's flag's CSS class * @@ -103,29 +69,17 @@ private function getFlagCssClass() return "c-flag--" . strtolower($this->getISO()); } - /** - * Given a country's ISO, get its ID - * - * @param string $iso The two-letter ISO code of the country - * @return int The country's database ID - */ - public static function getIdFromISO($iso) - { - return self::fetchIdFrom($iso, 'iso'); - } - /** * Get a query builder for countries * - * @return QueryBuilder + * @throws Exception When no database is configured for BZiON + * + * @return QueryBuilderFlex */ public static function getQueryBuilder() { - return new QueryBuilder('Country', array( - 'columns' => array( - 'name' => 'name', - ), - 'name' => 'name', - )); + return QueryBuilderFlex::createForModel(Country::class) + ->setNameColumn('name') + ; } } diff --git a/models/Invitation.php b/models/Invitation.php index 33a65eae..5637b3b8 100644 --- a/models/Invitation.php +++ b/models/Invitation.php @@ -28,7 +28,13 @@ class Invitation extends UrlModel * The ID of the team a player was invited to * @var int */ - protected $team; + protected $team_id; + + /** + * The time the invitation was sent + * @var TimeDate + */ + protected $sent; /** * The time the invitation will expire @@ -40,13 +46,27 @@ class Invitation extends UrlModel * The optional message sent to a player to join a team * @var string */ - protected $text; + protected $message; + + /** + * @var int + */ + protected $status; /** - * The name of the database table used for queries + * An array of valid statuses an Invitation can be in. + * + * @var int[] */ + protected static $validStatuses = [self::STATUS_PENDING, self::STATUS_ACCEPTED, self::STATUS_DENIED]; + + const DELETED_COLUMN = 'is_deleted'; + + const STATUS_PENDING = 0; + const STATUS_ACCEPTED = 1; + const STATUS_DENIED = 2; - const TABLE = "invitations"; + const TABLE = 'invitations'; /** * {@inheritdoc} @@ -55,37 +75,19 @@ protected function assignResult($invitation) { $this->invited_player = $invitation['invited_player']; $this->sent_by = $invitation['sent_by']; - $this->team = $invitation['team']; + $this->team_id = $invitation['team']; + $this->sent = TimeDate::fromMysql($invitation['sent']); $this->expiration = TimeDate::fromMysql($invitation['expiration']); - $this->text = $invitation['text']; + $this->status = self::castStatus($invitation['status']); + $this->is_deleted = $invitation['is_deleted']; } /** - * Send an invitation to join a team - * @param int $to The ID of the player who will receive the invitation - * @param int $teamid The team ID to which a player has been invited to - * @param int|null $from The ID of the player who sent it - * @param string $message (Optional) The message that will be displayed to the person receiving the invitation - * @param string|TimeDate|null $expiration The expiration time of the invitation (defaults to 1 week from now) - * @return Invitation The object of the invitation just sent + * {@inheritdoc} */ - public static function sendInvite($to, $teamid, $from = null, $message = "", $expiration = null) + protected function assignLazyResult($invitation) { - if ($expiration === null) { - $expiration = TimeDate::now()->addWeek(); - } else { - $expiration = Timedate::from($expiration); - } - - $invitation = self::create(array( - "invited_player" => $to, - "sent_by" => $from, - "team" => $teamid, - "text" => $message, - "expiration" => $expiration->toMysql(), - )); - - return $invitation; + $this->message = $invitation['text']; } /** @@ -115,7 +117,17 @@ public function getSentBy() */ public function getTeam() { - return Team::get($this->team); + return Team::get($this->team_id); + } + + /** + * Get the timestamp of when the invitation was sent. + * + * @return TimeDate + */ + public function getSendTimestamp() + { + return $this->sent; } /** @@ -128,38 +140,180 @@ public function getExpiration() return $this->expiration->copy(); } + /** + * Get the optional message sent to a player to join a team + * + * @return string + */ + public function getMessage() + { + $this->lazyLoad(); + + return $this->message; + } + + /** + * Get the current status of the Invitation. + * + * @see Invitation::STATUS_PENDING + * @see Invitation::STATUS_ACCEPTED + * @see Invitation::STATUS_DENIED + * + * @since 0.11.0 + * + * @return int + */ + public function getStatus() + { + return $this->status; + } + + /** + * Whether or not an invitation has expired + * + * @return bool + */ + public function isExpired() + { + return $this->expiration->lt(TimeDate::now()); + } + /** * Mark the invitation as having expired * * @return self */ - public function updateExpiration() + public function setExpired() { return $this->updateProperty($this->expiration, 'expiration', TimeDate::now()); } /** - * Get the optional message sent to a player to join a team + * Update the status for this Invitation * - * @return string + * @param int $statusValue + * + * @see Invitation::STATUS_PENDING + * @see Invitation::STATUS_ACCEPTED + * @see Invitation::STATUS_DENIED + * + * @since 0.11.0 + * + * @throws InvalidArgumentException When an invalid status is given as an argument + * + * @return static + */ + public function setStatus($statusValue) + { + if (!in_array($statusValue, self::$validStatuses)) { + throw new InvalidArgumentException('Invalid value was used; see Invitation::$validStatuses for valid values.'); + } + + return $this->updateProperty($this->status, 'status', $statusValue); + } + + /** + * Send an invitation to join a team + * + * @param int $playerID The ID of the player who will receive the invitation + * @param int $teamID The team ID to which a player has been invited to + * @param int|null $from The ID of the player who sent it + * @param string $message (Optional) The message that will be displayed to the person receiving the invitation + * @param string|TimeDate|null $expiration The expiration time of the invitation (defaults to 1 week from now) + * + * @return Invitation The object of the invitation just sent */ - public function getText() + public static function sendInvite($playerID, $teamID, $from = null, $message = '', $expiration = null) { - return $this->text; + if ($expiration === null) { + $expiration = TimeDate::now()->addWeek(); + } else { + $expiration = Timedate::from($expiration); + } + + $invitation = self::create([ + 'invited_player' => $playerID, + 'sent_by' => $from, + 'team' => $teamID, + 'sent' => TimeDate::now()->toMysql(), + 'expiration' => $expiration->toMysql(), + 'message' => $message, + 'status' => self::STATUS_PENDING, + ]); + + return $invitation; + } + + /** + * {@inheritdoc} + */ + public static function getQueryBuilder() + { + return QueryBuilderFlex::createForModel(Invitation::class); + } + + /** + * {@inheritdoc} + */ + public static function getEagerColumnsList() + { + return [ + 'id', + 'invited_player', + 'sent_by', + 'team', + 'sent', + 'expiration', + 'status', + 'is_deleted', + ]; + } + + /** + * {@inheritdoc} + */ + public static function getLazyColumnsList() + { + return [ + 'message', + ]; } /** * Find whether there are unexpired invitations for a player and a team * - * @param int $player - * @param int $team + * @param Player|int $player + * @param Team|int $team + * * @return int */ - public static function hasOpenInvitation($player, $team) + public static function playerHasInvitationToTeam($player, $team) { - return self::fetchCount( - "WHERE invited_player = ? AND team = ? AND expiration > UTC_TIMESTAMP()", - array($player, $team) - ); + return (bool)self::getQueryBuilder() + ->where('invited_player', '=', $player) + ->where('team', '=', $team) + ->where('expiration', '<', 'UTC_TIMESTAMP()') + ->count() + ; + } + + /** + * Cast a value to a valid Invitation status. + * + * @param int $status + * + * @see Invitation::STATUS_PENDING + * @see Invitation::STATUS_ACCEPTED + * @see Invitation::STATUS_DENIED + * + * @return int + */ + protected static function castStatus($status) + { + if (in_array($status, self::$validStatuses)) { + return $status; + } + + return self::STATUS_PENDING; } } diff --git a/models/Map.php b/models/Map.php index 3a481294..799cc3c1 100644 --- a/models/Map.php +++ b/models/Map.php @@ -65,14 +65,13 @@ class Map extends AvatarModel implements NamedModel protected $game_mode; /** - * The status of the map - * @var string + * Whether or not this map is currently not in rotation on the official game servers + * + * @var bool */ - protected $status; + protected $is_inactive; - /** - * The name of the database table used for queries - */ + const DELETED_COLUMN = 'is_deleted'; const TABLE = "maps"; /** @@ -103,7 +102,8 @@ protected function assignResult($map) $this->ricochet = $map['ricochet']; $this->jumping = $map['jumping']; $this->game_mode = $map['game_mode']; - $this->status = $map['status']; + $this->is_inactive = $map['is_inactive']; + $this->is_deleted = $map['is_deleted']; } /** @@ -113,18 +113,18 @@ protected function assignResult($map) * @param string|null $alias The custom API-friendly alias of the map * @param string $description The description of the map * @param string|null $avatar An image of the map - * @param string $status The status of the map (active, hidden, disabled or deleted) + * + * @since 0.11.0 The `$status` argument has been removed * * @return static */ - public static function addMap($name, $alias = null, $description = '', $avatar = null, $status = 'active') + public static function addMap($name, $alias = null, $description = '', $avatar = null) { return self::create(array( 'name' => $name, 'alias' => $alias, 'description' => $description, 'avatar' => $avatar, - 'status' => $status )); } @@ -211,6 +211,18 @@ public function isJumpingEnabled() return (bool)$this->jumping; } + /** + * Get whether or not this map is inactive. + * + * An inactive map is one that is not currently in rotation on official match servers. + * + * @return bool + */ + public function isInactive() + { + return (bool)$this->is_inactive; + } + /** * Set the name of the map * @@ -308,6 +320,18 @@ public function setGameMode($game_mode) return $this->updateProperty($this->game_mode, 'game_mode', $game_mode); } + /** + * Set whether or not this map is in active rotation on official match servers. + * + * @param bool $inactive + * + * @return static + */ + public function setInactive($inactive) + { + return $this->updateProperty($this->is_inactive, 'is_inactive', $inactive); + } + /** * Get the number of matches played on this map * @@ -323,16 +347,28 @@ public function countMatches() /** * Get a query builder for news - * @return QueryBuilder + * + * @throws Exception + * + * @return QueryBuilderFlex */ public static function getQueryBuilder() { - return new QueryBuilder('Map', array( - 'columns' => array( - 'name' => 'name', - 'status' => 'status' - ), - 'name' => 'name' - )); + return QueryBuilderFlex::createForModel(Map::class) + ->setNameColumn('name') + ; + } + + /** + * {@inheritdoc} + */ + public static function getActiveModels(QueryBuilderFlex &$qb) + { + $qb + ->whereNot(self::DELETED_COLUMN, '=', self::DELETED_VALUE) + ->whereNot('is_inactive', '=', true) + ; + + return true; } } diff --git a/models/News.php b/models/News.php index 2a8b44ae..2e17bf2d 100644 --- a/models/News.php +++ b/models/News.php @@ -54,11 +54,10 @@ class News extends UrlModel implements NamedModel */ protected $editor; - const DEFAULT_STATUS = 'published'; + /** @var bool Whether or not the News item is a draft */ + protected $is_draft; - /** - * The name of the database table used for queries - */ + const DELETED_COLUMN = 'is_deleted'; const TABLE = "news"; const CREATE_PERMISSION = Permission::CREATE_NEWS; @@ -78,7 +77,8 @@ protected function assignResult($news) $this->updated = TimeDate::fromMysql($news['updated']); $this->author = $news['author']; $this->editor = $news['editor']; - $this->status = $news['status']; + $this->is_draft = $news['is_draft']; + $this->is_deleted = $news['is_deleted']; } /** @@ -181,6 +181,16 @@ public function getName() return $this->getSubject(); } + /** + * Get whether or not this news article is + * + * @return bool + */ + public function isDraft() + { + return $this->is_draft; + } + /** * {@inheritdoc} */ @@ -197,6 +207,18 @@ public static function getRouteName($action = 'show') return "news_$action"; } + /** + * Update the "draft" status of a post + * + * @param bool $draft + * + * @return static + */ + public function setDraft($draft) + { + return $this->updateProperty($this->is_draft, 'is_draft', $draft); + } + /** * Update the content of a post * @@ -248,6 +270,8 @@ public function updateCategory($categoryID) */ public function updateStatus($status = 'published') { + @trigger_error('The `status` column of the News article has been deprecated. Use `is_draft` or `is_deleted`', E_USER_DEPRECATED); + return $this->updateProperty($this->status, 'status', $status); } @@ -262,14 +286,6 @@ public function updateSubject($subject) return $this->updateProperty($this->subject, 'subject', $subject); } - /** - * {@inheritdoc} - */ - public static function getActiveStatuses() - { - return array('published', 'revision'); - } - /** * Add a new news article * @@ -277,20 +293,20 @@ public static function getActiveStatuses() * @param string $content The content of the article * @param int $authorID The ID of the author * @param int $categoryId The ID of the category this article will be published under - * @param string $status The status of the article: 'published', 'disabled', or 'deleted' + * @param bool $is_draft Whether or not the added news item should be stored as a draft * * @return News An object representing the article that was just created or false if the article was not created */ - public static function addNews($subject, $content, $authorID, $categoryId = 1, $status = 'published') + public static function addNews($subject, $content, $authorID, $categoryId = 1, $is_draft = false) { - return self::create(array( + return self::create([ 'category' => $categoryId, 'subject' => $subject, 'content' => $content, 'author' => $authorID, 'editor' => $authorID, - 'status' => $status, - ), array('created', 'updated')); + 'is_draft' => $is_draft, + ], ['created', 'updated']); } /** @@ -300,38 +316,37 @@ public static function addNews($subject, $content, $authorID, $categoryId = 1, $ * @param int $limit The amount of matches to be retrieved * @param bool $getDrafts Whether or not to fetch drafts * + * @throws Exception When a database is not configured in BZiON + * * @return News[] An array of news objects */ public static function getNews($start = 0, $limit = 5, $getDrafts = false) { - $ignoredStatuses[] = "deleted"; - - if (!$getDrafts) { - $ignoredStatuses[] = "draft"; + $qb = self::getQueryBuilder() + ->limit($limit) + ->offset($start) + ->orderBy('created', 'DESC') + ->active() + ; + + if ($getDrafts) { + $qb->orWhere('is_draft', '=', true); } - return self::arrayIdToModel( - self::fetchIdsFrom( - "status", $ignoredStatuses, true, - "ORDER BY created DESC LIMIT $limit OFFSET $start" - ) - ); + return $qb->getModels(true); } /** * Get a query builder for news - * @return QueryBuilder + * + * @throws Exception + * + * @return QueryBuilderFlex */ public static function getQueryBuilder() { - return new QueryBuilder('News', array( - 'columns' => array( - 'subject' => 'subject', - 'category' => 'category', - 'created' => 'created', - 'status' => 'status' - ), - 'name' => 'subject' - )); + return QueryBuilderFlex::createForModel(News::class) + ->setNameColumn('subject') + ; } } diff --git a/models/NewsCategory.php b/models/NewsCategory.php index 0386bb22..90a62f68 100644 --- a/models/NewsCategory.php +++ b/models/NewsCategory.php @@ -8,6 +8,7 @@ /** * @TODO Create permissions for creating, editing, and modifying categories + * @TODO Set up methods to modify the News Categories */ /** @@ -16,17 +17,15 @@ */ class NewsCategory extends AliasModel { - /** - * Whether or not the category is protected from being deleted - * @var bool - */ - protected $protected; + /** @var bool Whether or not the category is protected from being deleted from the UI */ + protected $is_protected; + + /** @var bool When set to true, no new articles can be assigned this category */ + protected $is_read_only; const DEFAULT_STATUS = 'enabled'; - /** - * The name of the database table used for queries - */ + const DELETED_COLUMN = 'is_deleted'; const TABLE = "news_categories"; /** @@ -36,58 +35,33 @@ protected function assignResult($category) { $this->alias = $category['alias']; $this->name = $category['name']; - $this->protected = $category['protected']; - $this->status = $category['status']; + $this->is_protected = $category['is_protected']; + $this->is_deleted = $category['is_deleted']; } /** * Delete a category. Only delete a category if it is not protected - */ - public function delete() - { - // Get any articles using this category - $articles = News::fetchIdsFrom("category", $this->getId()); - - // Only delete a category if it is not protected and is not being used - if (!$this->isProtected() && count($articles) == 0) { - parent::delete(); - } - } - - /** - * Disable the category * - * @return void + * @throws DeletionDeniedException + * @throws Exception */ - public function disableCategory() + public function delete() { - if ($this->getStatus() != "disabled") { - $this->status = "disabled"; - $this->update("status", "disabled"); + $hasArticles = (bool) News::getQueryBuilder() + ->where('category', '=', $this->getId()) + ->active() + ->count() + ; + + if ($hasArticles) { + throw new DeletionDeniedException('This category has news articles and cannot be deleted.'); } - } - /** - * Enable the category - * - * @return void - */ - public function enableCategory() - { - if ($this->getStatus() != "enabled") { - $this->status = "enabled"; - $this->update("status", "enabled"); + if ($this->isProtected()) { + throw new DeletionDeniedException('This category is protected and cannot be deleted.'); } - } - /** - * Get the status of the category - * - * @return string Either 'enabled', 'disabled', or 'deleted' - */ - public function getStatus() - { - return $this->status; + parent::delete(); } /** @@ -97,32 +71,45 @@ public function getStatus() * @param int $limit The amount of matches to be retrieved * @param bool $getDrafts Whether or not to fetch drafts * + * @throws \Pixie\Exception + * @throws Exception + * * @return News[] An array of news objects */ public function getNews($start = 0, $limit = 5, $getDrafts = false) { - $ignoredStatuses = ""; - - if (!$getDrafts) { - $ignoredStatuses = "'draft', "; + $qb = News::getQueryBuilder() + ->limit($limit) + ->offset($start) + ->active() + ->where('category', '=', $this->getId()) + ; + + if ($getDrafts) { + $qb->whereNot('is_draft', '=', true); } - $ignoredStatuses .= "'deleted'"; - - $query = "WHERE status NOT IN ($ignoredStatuses) AND category = ? "; - $query .= "ORDER BY created DESC LIMIT $limit OFFSET $start"; - - return News::arrayIdToModel(News::fetchIds($query, array($this->getId()))); + return $qb->getModels(true); } /** - * Check if the category is protected from being deleted + * Check if the category is protected from being deleted. * * @return bool Whether or not the category is protected */ public function isProtected() { - return (bool) $this->protected; + return (bool) $this->is_protected; + } + + /** + * Check if new News article can be assigned this category. + * + * @return bool + */ + public function isReadOnly() + { + return (bool) $this->is_read_only; } /** @@ -135,41 +122,39 @@ public function isProtected() public static function addCategory($name) { return self::create(array( - 'alias' => self::generateAlias($name), - 'name' => $name, - 'protected' => 0, - 'status' => 'enabled' + 'alias' => self::generateAlias($name), + 'name' => $name, )); } /** * Get all of the categories for the news * + * @throws Exception + * * @return NewsCategory[] An array of categories */ public static function getCategories() { - return self::arrayIdToModel( - self::fetchIdsFrom( - "status", array("deleted"), true, - "ORDER BY name ASC" - ) - ); + return self::getQueryBuilder() + ->orderBy('name', 'ASC') + ->active() + ->getModels(true) + ; } /** - * Get a query builder for news categories - * @return QueryBuilder + * Get a query builder for news categories. + * + * @throws Exception + * + * @return QueryBuilderFlex */ public static function getQueryBuilder() { - return new QueryBuilder('NewsCategory', array( - 'columns' => array( - 'name' => 'name', - 'status' => 'status' - ), - 'name' => 'name', - )); + return QueryBuilderFlex::createForModel(NewsCategory::class) + ->setNameColumn('name') + ; } /** diff --git a/models/Page.php b/models/Page.php index a8832fed..f4524625 100644 --- a/models/Page.php +++ b/models/Page.php @@ -36,18 +36,13 @@ class Page extends AliasModel */ protected $author; - /** - * Whether the page is the home page - * @var bool - */ - protected $home; + protected $is_draft; + protected $is_unlisted; const DEFAULT_STATUS = 'live'; - /** - * The name of the database table used for queries - */ - const TABLE = "pages"; + const DELETED_COLUMN = 'is_deleted'; + const TABLE = 'pages'; const CREATE_PERMISSION = Permission::CREATE_PAGE; const EDIT_PERMISSION = Permission::EDIT_PAGE; @@ -62,8 +57,9 @@ protected function assignResult($page) $this->name = $page['name']; $this->alias = $page['alias']; $this->author = $page['author']; - $this->home = $page['home']; - $this->status = $page['status']; + $this->is_unlisted = $page['is_unlisted']; + $this->is_draft = $page['is_draft']; + $this->is_deleted = $page['is_deleted']; } /** @@ -119,28 +115,37 @@ public function getAuthor() } /** - * Get the status of the page - * @return string + * Get whether or not this Page is a draft. + * + * @since 0.11.0 + * + * @return bool */ - public function getStatus() + public function isDraft() { - return $this->status; + return (bool)$this->is_draft; } /** - * Find out whether this is the homepage + * Get whether or not this Page was unlisted. + * + * An unlisted page will not appear in the secondary navigation. + * + * @since 0.11.0 + * * @return bool */ - public function isHomePage() + public function isUnlisted() { - return $this->home; + return (bool)$this->is_unlisted; } /** * Set the content of the page * * @param string $content - * @return self + * + * @return static */ public function setContent($content) { @@ -148,14 +153,27 @@ public function setContent($content) } /** - * Set the status of the page + * Set the draft status for this page. * - * @param string $status One of "live", "revision" or "disabled" - * @return self + * @param bool $draft + * + * @return static + */ + public function setDraft($draft) + { + return $this->updateProperty($this->is_draft, 'is_draft', $draft); + } + + /** + * Set the unlisted status for this page. + * + * @param bool $unlisted + * + * @return static */ - public function setStatus($status) + public function setUnlisted($unlisted) { - return $this->updateProperty($this->status, "status", $status); + return $this->updateProperty($this->is_unlisted, 'is_unlisted', $unlisted); } /** @@ -170,23 +188,27 @@ public function updateEditTimestamp() /** * Create a new Page * - * @param string $title The title of the page - * @param string $content The content of page - * @param int $authorID The ID of the author - * @param string $status Page status: 'live','disabled',or 'deleted' + * @param string $title The title of the page + * @param string $content The content of page + * @param int $authorID The ID of the author + * @param bool $is_draft Whether or not the page should be saved as a draft + * @param bool $is_unlisted Whether or not the page should be unlisted + * + * @since 0.11.0 The former enum $status parameter has been changed to the boolean $is_draft. The $is_unlisted + * argument has been added. * * @return Page An object representing the page that was just created */ - public static function addPage($title, $content, $authorID, $status = "live") + public static function addPage($title, $content, $authorID, $is_draft = false, $is_unlisted = false) { - return self::create(array( + return self::create([ 'name' => $title, 'alias' => self::generateAlias($title), 'content' => $content, 'author' => $authorID, - 'home' => 0, - 'status' => $status, - ), array('created', 'updated')); + 'is_draft' => (bool)$is_draft, + 'is_unlisted' => (bool)$is_unlisted, + ], ['created', 'updated']); } /** @@ -244,18 +266,42 @@ public static function getLazyColumns() } /** - * Get a query builder for pages - * @return QueryBuilder + * {@inheritdoc} */ public static function getQueryBuilder() { - return new QueryBuilder('Page', array( - 'columns' => array( - 'name' => 'name', - 'status' => 'status' - ), - 'name' => 'name' - )); + return QueryBuilderFlex::createForModel(Page::class) + ->setNameColumn('name') + ; + } + + /** + * {@inheritdoc} + */ + public static function getActiveModels(QueryBuilderFlex &$qb) + { + $qb + ->whereNot(self::DELETED_COLUMN, '=', self::DELETED_VALUE) + ->whereNot('is_draft', '=', true) + ; + + return true; + } + + /** + * {@inheritdoc} + */ + public static function getEagerColumnsList() + { + return [ + 'id', + 'name', + 'alias', + 'author', + 'is_draft', + 'is_deleted', + 'is_unlisted', + ]; } /** diff --git a/models/Permission.php b/models/Permission.php index 1c9ef616..55016f86 100644 --- a/models/Permission.php +++ b/models/Permission.php @@ -10,7 +10,7 @@ * A permission that is assigned to a role * @package BZiON\Models */ -class Permission extends Model +class Permission extends Model implements NamedModel { const ADD_BAN = "add_ban"; const ADD_MAP = "add_map"; @@ -67,9 +67,7 @@ class Permission extends Model */ protected $description; - /** - * The name of the database table used for queries - */ + const SYSTEM_MODEL = true; const TABLE = "permissions"; /** @@ -101,13 +99,16 @@ public function getName() /** * Get all of the existing permissions in the database + * + * @throws \Pixie\Exception + * * @return Permission[] An array of permissions */ public static function getPerms() { - return parent::arrayIdToModel( - self::fetchIds() - ); + return self::getQueryBuilder() + ->getModels() + ; } /** @@ -120,18 +121,18 @@ public static function getPermissionFromName($perm_name) return $perm_name; } - return self::get( - self::fetchIdFrom($perm_name, "name") - ); + return self::getQueryBuilder() + ->findModel($perm_name, 'name') + ; } + /** + * {@inheritdoc} + */ public static function getQueryBuilder() { - return new QueryBuilder("Permission", array( - 'columns' => array( - 'name' => 'name' - ), - 'name' => 'name' - )); + return QueryBuilderFlex::createForModel(Permission::class) + ->setNameColumn('name') + ; } } diff --git a/models/Player.php b/models/Player.php index d9bbb23d..ce383452 100644 --- a/models/Player.php +++ b/models/Player.php @@ -170,9 +170,9 @@ class Player extends AvatarModel implements NamedModel, DuplexUrlInterface, EloI private $matchActivity; - /** - * The name of the database table used for queries - */ + private $is_disabled; + + const DELETED_COLUMN = 'is_deleted'; const TABLE = "players"; /** @@ -193,9 +193,10 @@ protected function assignResult($player) $this->name = $player['username']; $this->alias = $player['alias']; $this->team = $player['team']; - $this->status = $player['status']; $this->avatar = $player['avatar']; $this->country = $player['country']; + $this->is_disabled = $player['is_disabled']; + $this->is_deleted = $player['is_deleted']; if (array_key_exists('activity', $player)) { $this->matchActivity = ($player['activity'] != null) ? $player['activity'] : 0.0; @@ -1377,21 +1378,58 @@ public static function getLazyColumns($prefix = null) return self::formatColumns($prefix, $columns); } + /** + * {@inheritdoc} + */ + public static function getEagerColumnsList() + { + return [ + 'id', + 'bzid', + 'team', + 'username', + 'alias', + 'avatar', + 'country', + 'is_disabled', + 'is_deleted', + ]; + } + + /** + * {@inheritdoc} + */ + public static function getLazyColumnsList() + { + return [ + 'email', + 'verified', + 'receives', + 'confirm_code', + 'outdated', + 'description', + 'theme', + 'color_blind_enabled', + 'timezone', + 'joined', + 'last_login', + 'last_match', + 'admin_notes', + ]; + } + /** * Get a query builder for players + * + * @throws Exception + * * @return PlayerQueryBuilder */ public static function getQueryBuilder() { - return new PlayerQueryBuilder('Player', array( - 'columns' => array( - 'name' => 'username', - 'team' => 'team', - 'outdated' => 'outdated', - 'status' => 'status', - ), - 'name' => 'name', - )); + return PlayerQueryBuilder::createForModel(Player::class) + ->setNameColumn('username') + ; } /** diff --git a/models/Server.php b/models/Server.php index a393997b..843f7962 100644 --- a/models/Server.php +++ b/models/Server.php @@ -69,8 +69,27 @@ class Server extends UrlModel implements NamedModel protected $updated; /** - * The name of the database table used for queries + * Whether or not this server is capable of hosting official matches. + * + * @var bool + */ + protected $is_official_server; + + /** + * Whether or not this server is dedicated to being a replay server. + * + * @var bool */ + protected $is_replay_server; + + /** + * Whether or not this server has been marked as "inactive" and is only kept for historical purposes. + * + * @var bool + */ + protected $is_inactive; + + const DELETED_COLUMN = 'is_deleted'; const TABLE = "servers"; const CREATE_PERMISSION = Permission::ADD_SERVER; @@ -92,36 +111,9 @@ protected function assignResult($server) $this->info = unserialize($server['info']); $this->api_key = ApiKey::get($server['api_key']); $this->updated = TimeDate::fromMysql($server['updated']); - $this->status = $server['status']; - } - - /** - * Add a new server - * - * @param string $name The name of the server - * @param string $domain The domain of the server (e.g. server.com) - * @param string $port The port of the server (e.g. 5154) - * @param int $country The ID of the country - * @param int $owner The ID of the server owner - * - * @return Server An object that represents the sent message - */ - public static function addServer($name, $domain, $port, $country, $owner) - { - $key = ApiKey::getKeyByOwner($owner); - - $server = self::create(array( - 'name' => $name, - 'domain' => $domain, - 'port' => $port, - 'country' => $country, - 'owner' => $owner, - 'api_key' => $key->getId(), - 'status' => 'active', - ), 'updated'); - $server->forceUpdate(); - - return $server; + $this->is_official_server = $server['is_official_server']; + $this->is_replay_server = $server['is_replay_server']; + $this->is_inactive = $server['is_inactive']; } /** @@ -319,6 +311,38 @@ public function getLastUpdate() return $this->updated; } + /** + * Get whether or not this server is only kept for historical purposes. + * + * The server is now "retired" or no longer used but is **not** soft deleted. + * + * @return bool + */ + public function isInactive() + { + return (bool)$this->is_inactive; + } + + /** + * Get whether or not this server is capable of hosting official matches + * + * @return bool + */ + public function isOfficialServer() + { + return (bool)$this->is_official_server; + } + + /** + * Get whether or not this server is dedicated to serving replays. + * + * @return bool + */ + public function isReplayServer() + { + return (bool)$this->is_replay_server; + } + /** * Set the name of the server * @@ -399,29 +423,94 @@ public function setCountry($countryId) } /** - * Get all the servers in the database that have an active status - * @return Server[] An array of server objects + * Set this server's inactivity status. + * + * @param bool $inactive + * + * @return static */ - public static function getServers() + public function setInactive($inactive) { - return self::arrayIdToModel(self::fetchIdsFrom("status", array("active"), false, "ORDER BY name")); + return $this->updateProperty($this->is_inactive, 'is_inactive', $inactive); + } + + /** + * Set the official match capabilities of this server. + * + * @param bool $matchServer + * + * @return static + */ + public function setOfficialServer($matchServer) + { + return $this->updateProperty($this->is_official_server, 'is_official_server', $matchServer); + } + + /** + * Set the replay status of this server. + * + * @param bool $replayServer + * + * @return static + */ + public function setReplayServer($replayServer) + { + return $this->updateProperty($this->is_replay_server, 'is_replay_server', $replayServer); + } + + /** + * Add a new server + * + * @param string $name The name of the server + * @param string $domain The domain of the server (e.g. server.com) + * @param string $port The port of the server (e.g. 5154) + * @param int $countryID The ID of the country + * @param int $ownerID The ID of the server owner + * + * @return Server An object that represents the sent message + */ + public static function addServer($name, $domain, $port, $countryID, $ownerID) + { + $key = ApiKey::getKeyByOwner($ownerID); + + $server = self::create([ + 'name' => $name, + 'domain' => $domain, + 'port' => $port, + 'country' => $countryID, + 'owner' => $ownerID, + 'api_key' => $key->getId(), + ], 'updated'); + $server->forceUpdate(); + + return $server; } /** * Get a query builder for servers - * @return QueryBuilder + * + * @throws Exception + * + * @return QueryBuilderFlex */ public static function getQueryBuilder() { - return new QueryBuilder('Server', array( - 'columns' => array( - 'name' => 'name', - 'domain' => 'domain', - 'port' => 'port', - 'status' => 'status', - ), - 'name' => 'name' - )); + return QueryBuilderFlex::createForModel(Server::class) + ->setNameColumn('name') + ; + } + + /** + * {@inheritdoc} + */ + public static function getActiveModels(QueryBuilderFlex &$qb) + { + $qb + ->whereNot(self::DELETED_COLUMN, '=', self::DELETED_VALUE) + ->whereNot('is_inactive', '=', true) + ; + + return true; } /** @@ -429,6 +518,9 @@ public static function getQueryBuilder() * * @param string $address The address in the format of `domain:port` * + * @throws \Pixie\Exception + * @throws Exception + * * @return static */ public static function fetchFromAddress($address) @@ -439,16 +531,15 @@ public static function fetchFromAddress($address) list($domain, $port) = explode(':', $address); - $qb = self::getQueryBuilder(); - $query = $qb - ->where('domain')->equals($domain) - ->where('port')->equals($port) + $results = self::getQueryBuilder() + ->where('domain', '=', $domain) + ->where('port', '=', $port) ->active() - ->getModels($fast = true) + ->getModels(true) ; - if (count($query) > 0) { - return $query[0]; + if (count($results) > 0) { + return $results[0]; } return Server::get(0); diff --git a/models/Team.php b/models/Team.php index 5b59c4b0..0da61003 100644 --- a/models/Team.php +++ b/models/Team.php @@ -85,17 +85,15 @@ class Team extends AvatarModel implements TeamInterface, DuplexUrlInterface, Elo protected $members; /** - * The team's status + * Whether or not the team is closed. A closed team only allows new members through invitations. * - * @var string + * @var bool */ - protected $status; + protected $is_closed; const DEFAULT_STATUS = 'closed'; - /** - * The name of the database table used for queries - */ + const DELETED_COLUMN = 'is_deleted'; const TABLE = "teams"; /** @@ -115,7 +113,6 @@ protected function assignResult($team) { $this->name = $team['name']; $this->alias = $team['alias']; - $this->description = $team['description']; $this->avatar = $team['avatar']; $this->created = TimeDate::fromMysql($team['created']); $this->elo = $team['elo']; @@ -124,13 +121,19 @@ protected function assignResult($team) $this->matches_lost = $team['matches_lost']; $this->matches_draw = $team['matches_draw']; $this->members = $team['members']; - $this->status = $team['status']; + $this->is_closed = $team['is_closed']; + $this->is_deleted = $team['is_deleted']; $this->matches_total = $this->matches_won + $this->matches_lost + $this->matches_draw; $this->activity = isset($team['activity']) ? $team['activity'] : 0; } + protected function assignLazyResult($result) + { + $this->description = $result['description']; + } + /** * Adds a new member to the team * @@ -237,6 +240,8 @@ public function getCreationDate() */ public function getDescription() { + $this->lazyLoad(); + return $this->description; } @@ -605,21 +610,12 @@ public static function getAlphabeticalSort() /** * {@inheritdoc} */ - public static function getActiveStatuses() - { - return array('open', 'closed'); - } - - /** - * {@inheritdoc} - */ - public static function getEagerColumns($prefix = null) + public static function getEagerColumnsList() { - $columns = [ + return [ 'id', 'name', 'alias', - 'description', 'avatar', 'created', 'elo', @@ -628,28 +624,31 @@ public static function getEagerColumns($prefix = null) 'matches_lost', 'matches_draw', 'members', - 'status', + 'is_closed', + 'is_deleted', ]; + } - return self::formatColumns($prefix, $columns); + /** + * {@inheritdoc} + */ + public static function getLazyColumnsList() + { + return [ + 'description', + ]; } /** - * Get a query builder for teams + * {@inheritdoc} + * * @return TeamQueryBuilder */ public static function getQueryBuilder() { - return new TeamQueryBuilder('Team', array( - 'columns' => array( - 'name' => 'name', - 'elo' => 'elo', - 'leader' => 'leader', - 'members' => 'members', - 'status' => 'status' - ), - 'name' => 'name', - )); + return TeamQueryBuilder::createForModel(Team::class) + ->setNameColumn('name') + ; } /** diff --git a/models/Visit.php b/models/Visit.php index c57a75f2..83779836 100644 --- a/models/Visit.php +++ b/models/Visit.php @@ -89,17 +89,14 @@ public static function enterVisit($visitor, $ip, $host, $user_agent, $referrer, /** * Get a query builder for players - * @return QueryBuilder + * + * @throws Exception + * + * @return QueryBuilderFlex */ public static function getQueryBuilder() { - return new VisitQueryBuilder('Visit', array( - 'columns' => array( - 'ip' => 'ip', - 'timestamp' => 'timestamp' - ), - 'name' => 'name', - )); + return VisitQueryBuilder::createForModel(Visit::class); } /** diff --git a/src/Controller/Controller.php b/src/Controller/Controller.php index e7b433bf..0fd2f6c4 100644 --- a/src/Controller/Controller.php +++ b/src/Controller/Controller.php @@ -375,14 +375,14 @@ public static function getName() * * @param string $type The model whose query builder we should get (null * to get the builder of the controller's model) - * @return QueryBuilder + * @return QueryBuilder|QueryBuilderFlex */ public static function getQueryBuilder($type = null) { $type = ($type) ?: static::getName(); return $type::getQueryBuilder() - ->visibleTo(static::getMe(), static::getRequest()->get('showDeleted')); + ->visibleTo(static::getMe(), (bool)static::getRequest()->get('showDeleted')); } /** diff --git a/src/Database.php b/src/Database.php index fb366458..6955f55d 100644 --- a/src/Database.php +++ b/src/Database.php @@ -112,6 +112,14 @@ public static function getInstance() Service::getParameter('bzion.testing.password'), Service::getParameter('bzion.testing.database') ); + + $config = [ + 'driver' => 'mysql', + 'host' => Service::getParameter('bzion.testing.host'), + 'database' => Service::getParameter('bzion.testing.database'), + 'username' => Service::getParameter('bzion.testing.username'), + 'password' => Service::getParameter('bzion.testing.password'), + ]; } else { self::$Database = new self( Service::getParameter('bzion.mysql.host'), @@ -119,7 +127,20 @@ public static function getInstance() Service::getParameter('bzion.mysql.password'), Service::getParameter('bzion.mysql.database') ); + + $config = [ + 'driver' => 'mysql', + 'host' => Service::getParameter('bzion.mysql.host'), + 'database' => Service::getParameter('bzion.mysql.database'), + 'username' => Service::getParameter('bzion.mysql.username'), + 'password' => Service::getParameter('bzion.mysql.password'), + ]; } + + $config['charset'] = 'utf8'; + $config['collation'] = 'utf8_unicode_ci'; + + Service::setQueryBuilderConfig($config); } return self::$Database; diff --git a/src/Debug/DatabaseQuery.php b/src/Debug/DatabaseQuery.php index a18eaa7a..1c9ea535 100644 --- a/src/Debug/DatabaseQuery.php +++ b/src/Debug/DatabaseQuery.php @@ -72,7 +72,7 @@ class DatabaseQuery * @param string $query The MySQL query * @param array|null $params The query parameters */ - public function __construct(&$query, &$params) + public function __construct($query, $params) { $this->query = $query; $this->params = ($params !== false) ? array_values($params) : null; diff --git a/src/Exception/DeletionDeniedException.php b/src/Exception/DeletionDeniedException.php new file mode 100644 index 00000000..cb64d60f --- /dev/null +++ b/src/Exception/DeletionDeniedException.php @@ -0,0 +1,9 @@ + [ + new NotBlankModel(), + ], + 'label' => 'Invitation Recipient', + 'required' => true, + ]); + + $targetTeam = $builder + ->create('target_team', HiddenType::class, [ + 'data' => $this->editing->getId(), + ]) + ->setDataLocked(true) + ; + + $builder + ->add($targetTeam) + ->add('invited_player', $invitedPlayer) + ->add('message', TextareaType::class, [ + 'required' => false, + ]) + ->add('cancel', ButtonType::class, [ + 'label' => 'Cancel', + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'Invite', + 'attr' => [ + 'class' => 'c-button--blue pattern pattern--upward-stripes' + ] + ]) + ->addEventListener(FormEvents::POST_SUBMIT, [$this, 'checkPlayerEligibility']) + ; + + return $builder; + } + + public function checkPlayerEligibility(FormEvent $event) + { + $form = $event->getForm(); + + if ($form->has('invited_player')) { + $formElement = $form->get('invited_player'); + + /** @var \Player|null $proposedInvitee */ + $proposedInvitee = $formElement->getData(); + + if ($proposedInvitee === null) { + $formElement->addError(new FormError('Invited player not found.')); + return; + } + + if ($this->editing->isMember($proposedInvitee->getId())) { + $formElement->addError(new FormError('This player is already a member of that team.')); + } + + if (\Invitation::playerHasInvitationToTeam($proposedInvitee->getId(), $this->editing->getId())) { + $formElement->addError(new FormError('This player already has an invitation to this team.')); + } + } + } + + /** + * {@inheritdoc} + */ + public function enter($form) + { + $invite = \Invitation::sendInvite( + $form->get('invited_player')->getData(), + $form->get('target_team')->getData(), + $this->me->getId(), + $form->get('message')->getData() + ); + \Service::getDispatcher()->dispatch(Events::TEAM_INVITE, new TeamInviteEvent($invite)); + + return $invite; + } +} diff --git a/src/Form/Creator/MapFormCreator.php b/src/Form/Creator/MapFormCreator.php index dcb1fcee..a5cad785 100644 --- a/src/Form/Creator/MapFormCreator.php +++ b/src/Form/Creator/MapFormCreator.php @@ -113,6 +113,13 @@ protected function build($builder) 'multiple' => false, 'label' => 'Game Mode', ]) + ->add('is_inactive', CheckboxType::class, [ + 'label' => 'Map is inactive', + 'required' => false, + 'attr' => [ + 'data-help-message' => 'By checking this box, it means this map is no longer available on the official match servers via /maplist', + ], + ]) ; if ($this->editing) { @@ -144,6 +151,7 @@ public function fill($form, $map) $form->get('jumping')->setData($map->isJumpingEnabled()); $form->get('ricochet')->setData($map->isRicochetEnabled()); $form->get('game_mode')->setData($map->getGameMode()); + $form->get('is_inactive')->setData($map->isInactive()); } /** @@ -164,6 +172,7 @@ public function enter($form) ->setWorldSize($form->get('world_size')->getData()) ->setRandomlyGenerated($form->get('randomly_generated')->getData()) ->setGameMode($form->get('game_mode')->getData()) + ->setInactive($form->get('is_inactive')->getData()) ; } @@ -183,6 +192,7 @@ public function update($form, $map) $map->setJumpingEnabled($form->get('jumping')->getData()); $map->setRicochetEnabled($form->get('ricochet')->getData()); $map->setGameMode($form->get('game_mode')->getData()); + $map->setInactive($form->get('is_inactive')->getData()); if ($form->has('delete_avatar') && $form->get('delete_avatar')->isClicked()) { $map->resetAvatar(); diff --git a/src/Form/Creator/NewsFormCreator.php b/src/Form/Creator/NewsFormCreator.php index e3c42aec..24ac1465 100644 --- a/src/Form/Creator/NewsFormCreator.php +++ b/src/Form/Creator/NewsFormCreator.php @@ -8,11 +8,17 @@ namespace BZIon\Form\Creator; use BZIon\Form\Type\ModelType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; /** * Form creator for news + * + * @property \News|null $editing */ class NewsFormCreator extends ModelFormCreator { @@ -21,32 +27,41 @@ class NewsFormCreator extends ModelFormCreator */ protected function build($builder) { - return $builder + $builder ->add('category', new ModelType('NewsCategory'), array( 'constraints' => new NotBlank() )) - ->add('subject', 'text', array( - 'constraints' => array( - new NotBlank(), new Length(array( + ->add('subject', TextType::class, [ + 'constraints' => [ + new NotBlank(), + new Length([ 'max' => 100, - )), - ), - )) - ->add('content', 'textarea', array( + ]), + ], + ]) + ->add('content', TextareaType::class, array( 'constraints' => new NotBlank() )) - ->add('status', 'choice', array( - 'choices' => array( - 'published' => 'Public', - 'revision' => 'Revision', - 'draft' => 'Draft', - ), - )) - ->add('enter', 'submit', [ + ->add('publish', SubmitType::class, [ 'attr' => [ 'class' => 'c-button--blue pattern pattern--downward-stripes', ], - ]); + 'label' => 'Publish', + ]) + ; + + if ($this->editing === null || $this->editing->isDraft()) { + $builder + ->add('save_draft', SubmitType::class, [ + 'attr' => [ + 'class' => 'c-button--green pattern pattern--upward-stripes', + ], + 'label' => 'Save Draft' + ]) + ; + } + + return $builder; } /** @@ -59,7 +74,6 @@ public function fill($form, $article) $form->get('category')->setData($article->getCategory()); $form->get('subject')->setData($article->getSubject()); $form->get('content')->setData($article->getContent()); - $form->get('status')->setData($article->getStatus()); } /** @@ -69,12 +83,16 @@ public function fill($form, $article) */ public function update($form, $article) { - $article->updateCategory($form->get('category')->getData()->getId()) - ->updateSubject($form->get('subject')->getData()) - ->updateContent($form->get('content')->getData()) - ->updateStatus($form->get('status')->getData()) - ->updateLastEditor($this->me->getId()) - ->updateEditTimestamp(); + $saveDraft = $form->get('save_draft'); + + $article + ->updateCategory($form->get('category')->getData()->getId()) + ->updateSubject($form->get('subject')->getData()) + ->updateContent($form->get('content')->getData()) + ->setDraft($saveDraft && $saveDraft->isClicked()) + ->updateLastEditor($this->me->getId()) + ->updateEditTimestamp() + ; } /** @@ -82,12 +100,14 @@ public function update($form, $article) */ public function enter($form) { + $saveDraft = $form->get('save_draft'); + return \News::addNews( $form->get('subject')->getData(), $form->get('content')->getData(), $this->me->getId(), $form->get('category')->getData()->getId(), - $form->get('status')->getData() + ($saveDraft && $saveDraft->isClicked()) ); } } diff --git a/src/Form/Creator/PageFormCreator.php b/src/Form/Creator/PageFormCreator.php index dc42f993..9d4cafce 100644 --- a/src/Form/Creator/PageFormCreator.php +++ b/src/Form/Creator/PageFormCreator.php @@ -7,11 +7,17 @@ namespace BZIon\Form\Creator; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; /** * Form creator for pages + * + * @property \Page $editing */ class PageFormCreator extends ModelFormCreator { @@ -20,55 +26,75 @@ class PageFormCreator extends ModelFormCreator */ protected function build($builder) { - return $builder - ->add( - $builder->create('name', 'text', array( - 'constraints' => array( - new NotBlank(), new Length(array( - 'max' => 32, - )), - ), - 'data' => $this->controller->data->get('name') - ))->setDataLocked(false) - ) - ->add('content', 'textarea', array( - 'constraints' => new NotBlank() - )) - ->add('status', 'choice', array( - 'choices' => array( - 'live' => 'Public', - 'revision' => 'Revision', - 'disabled' => 'Disabled', - ), - 'description' => "'Revision' pages are accessible by all users but not listed in the menu, " . - "while 'Disabled' pages cannot be accessed by players." - )) - ->add('enter', 'submit', [ + $editingDraftOrCreatingNew = ($this->editing === null || $this->editing->isDraft()); + $editingPublishedPage = ($this->editing !== null && !$this->editing->isDraft()); + + $builder + ->add('name', TextType::class, [ + 'constraints' => [ + new NotBlank(), + new Length([ + 'max' => 32, + ]), + ], + ]) + ->add('content', TextareaType::class, [ + 'constraints' => [ + new NotBlank(), + ], + ]) + ->add('is_unlisted', CheckboxType::class, [ + 'label' => 'Unlisted Page', + 'required' => false, + 'attr' => [ + 'data-help-message' => 'This page will not be listed on the footer of the website', + ], + ]) + ->add('modify_draft', SubmitType::class, [ + 'attr' => [ + 'class' => 'c-button--green pattern pattern--upward-stripes', + ], + 'label' => $editingDraftOrCreatingNew ? 'Save Draft' : 'Unpublish Page', + ]) + ->add('submit', SubmitType::class, [ 'attr' => [ 'class' => 'c-button--blue pattern pattern--downward-stripes', ], - ]); + 'label' => $editingPublishedPage ? 'Save Changes' : 'Publish Page', + ]) + ; + + return $builder; } /** * {@inheritdoc} + * + * @param \Page $page */ public function fill($form, $page) { $form->get('name')->setData($page->getName()); $form->get('content')->setData($page->getContent()); - $form->get('status')->setData($page->getStatus()); + $form->get('is_unlisted')->setData($page->isUnlisted()); } /** * {@inheritdoc} + * + * @param \Page $page */ public function update($form, $page) { - $page->setName($form->get('name')->getData()) - ->setContent($form->get('content')->getData()) - ->setStatus($form->get('status')->getData()) - ->updateEditTimestamp(); + $saveDraft = $form->get('modify_draft'); + + $page + ->setName($form->get('name')->getData()) + ->setContent($form->get('content')->getData()) + ->setUnlisted($form->get('is_unlisted')->getData()) + ->setDraft($saveDraft->isClicked()) + ->updateEditTimestamp() + ; } /** @@ -80,7 +106,8 @@ public function enter($form) $form->get('name')->getData(), $form->get('content')->getData(), $this->me->getId(), - $form->get('status')->getData() + $form->get('modify_draft')->isClicked(), + $form->get('is_unlisted')->getData() ); } } diff --git a/src/Form/Creator/ProfileFormCreator.php b/src/Form/Creator/ProfileFormCreator.php index c8ddbac3..e4a18775 100644 --- a/src/Form/Creator/ProfileFormCreator.php +++ b/src/Form/Creator/ProfileFormCreator.php @@ -9,6 +9,7 @@ use BZIon\Form\Type\ModelType; use BZIon\Form\Type\TimezoneType; +use Country; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\EmailType; @@ -65,6 +66,9 @@ protected function build($builder) $themeSlugs = array_column(\Service::getSiteThemes(), 'slug'); $themes = array_combine($themeSlugs, $themeNames); + // Add all of our countries into the cache since flags are displayed + Country::getQueryBuilder()->addToCache(); + $builder ->add('description', TextareaType::class, array( 'constraints' => new Length(array('max' => 8000)), @@ -80,7 +84,7 @@ protected function build($builder) 'required' => false )) ->add('delete_avatar', SubmitType::class) - ->add('country', new ModelType('Country'), array( + ->add('country', new ModelType(Country::class), array( 'constraints' => new NotBlank(), 'data' => $this->editing->getCountry() )) diff --git a/src/Form/Creator/ServerFormCreator.php b/src/Form/Creator/ServerFormCreator.php index a56b96db..9b184ccb 100644 --- a/src/Form/Creator/ServerFormCreator.php +++ b/src/Form/Creator/ServerFormCreator.php @@ -9,6 +9,11 @@ use BZIon\Form\Type\AdvancedModelType; use BZIon\Form\Type\ModelType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; @@ -17,40 +22,67 @@ */ class ServerFormCreator extends ModelFormCreator { + const OFFICIAL_MATCH_SERVER = 'oms'; + const OFFICIAL_REPLAY_SERVER = 'ors'; + const PUBLIC_SERVER = 'ps'; + const PUBLIC_REPLAY_SERVER = 'prs'; + /** * {@inheritdoc} */ protected function build($builder) { return $builder - ->add('domain', 'text', array( - 'constraints' => array( - new NotBlank(), new Length(array( + ->add('domain', TextType::class, [ + 'constraints' => [ + new NotBlank(), + new Length([ 'max' => 50, - )), - ), - )) + ]), + ], + ]) ->add( - $builder->create('port', 'integer', array( + $builder->create('port', IntegerType::class, array( 'constraints' => new NotBlank(), 'data' => 5154 - ))->setDataLocked(false) // Don't lock the data so we can change - // the default value later if needed + ))->setDataLocked(false) // Don't lock the data so we can change the default value later if needed ) - ->add('name', 'text', array( - 'constraints' => array( - new NotBlank(), new Length(array( + ->add('name', TextType::class, [ + 'constraints' => [ + new NotBlank(), + new Length([ 'max' => 100, - )), - ), - )) - ->add('country', new ModelType('Country'), array( - 'constraints' => new NotBlank() - )) - ->add('owner', new AdvancedModelType('Player'), array( - 'constraints' => new NotBlank() - )) - ->add('enter', 'submit', [ + ]), + ], + ]) + ->add('country', new ModelType('Country'), [ + 'constraints' => [ + new NotBlank(), + ], + ]) + ->add('owner', new AdvancedModelType('Player'), [ + 'constraints' => [ + new NotBlank(), + ], + ]) + ->add('server_type', ChoiceType::class, [ + 'choices' => [ + self::OFFICIAL_MATCH_SERVER => 'Official Match Server', + self::OFFICIAL_REPLAY_SERVER => 'Official Replay Server', + self::PUBLIC_SERVER => 'Public Server', + self::PUBLIC_REPLAY_SERVER => 'Public Replay Server', + ], + 'required' => true, + 'label' => 'Server Type', + ]) + ->add('inactive', CheckboxType::class, [ + 'label' => 'Server Inactive', + 'required' => false, + 'attr' => [ + 'data-help-message' => 'When checked, that means this server is no longer active in hosting', + ], + ]) + ->add('enter', SubmitType::class, [ 'attr' => [ 'class' => 'c-button--blue pattern pattern--downward-stripes', ], @@ -60,6 +92,8 @@ protected function build($builder) /** * {@inheritdoc} + * + * @param \Server $server */ public function fill($form, $server) { @@ -68,19 +102,47 @@ public function fill($form, $server) $form->get('port')->setData($server->getPort()); $form->get('country')->setData($server->getCountry()); $form->get('owner')->setData($server->getOwner()); + $form->get('inactive')->setData($server->isInactive()); + + $serverType = $form->get('server_type'); + + if ($server->isOfficialServer()) { + if ($server->isReplayServer()) { + $serverType->setData(self::OFFICIAL_REPLAY_SERVER); + } + else { + $serverType->setData(self::OFFICIAL_MATCH_SERVER); + } + } + else { + if ($server->isReplayServer()) { + $serverType->setData(self::PUBLIC_REPLAY_SERVER); + } + else { + $serverType->setData(self::PUBLIC_SERVER); + } + } } /** * {@inheritdoc} + * + * @param \Server $server */ public function update($form, $server) { - $server->setName($form->get('name')->getData()) - ->setDomain($form->get('domain')->getData()) - ->setPort($form->get('port')->getData()) - ->setCountry($form->get('country')->getData()->getId()) - ->setOwner($form->get('owner')->getData()->getId()) - ->forceUpdate(); + $server + ->setName($form->get('name')->getData()) + ->setDomain($form->get('domain')->getData()) + ->setPort($form->get('port')->getData()) + ->setCountry($form->get('country')->getData()->getId()) + ->setOwner($form->get('owner')->getData()->getId()) + ->setInactive($form->get('inactive')->getData()) + ; + + $this->updateServerType($server, $form->get('server_type')->getData()); + + $server->forceUpdate(); } /** @@ -88,12 +150,43 @@ public function update($form, $server) */ public function enter($form) { - return \Server::addServer( + $server = \Server::addServer( $form->get('name')->getData(), $form->get('domain')->getData(), $form->get('port')->getData(), $form->get('country')->getData()->getId(), $form->get('owner')->getData()->getId() ); + + $server->setInactive($form->get('inactive')->getData()); + + $this->updateServerType($server, $form->get('server_type')->getData()); + + return $server; + } + + private function updateServerType(\Server $server, $serverType) + { + switch ($serverType) { + case self::OFFICIAL_MATCH_SERVER: + $server->setOfficialServer(true); + $server->setReplayServer(false); + break; + + case self::OFFICIAL_REPLAY_SERVER: + $server->setOfficialServer(true); + $server->setReplayServer(true); + break; + + case self::PUBLIC_SERVER: + $server->setOfficialServer(false); + $server->setReplayServer(false); + break; + + case self::PUBLIC_REPLAY_SERVER: + $server->setOfficialServer(false); + $server->setReplayServer(true); + break; + } } } diff --git a/src/Form/Type/AdvancedModelType.php b/src/Form/Type/AdvancedModelType.php index 55db487e..faf13416 100644 --- a/src/Form/Type/AdvancedModelType.php +++ b/src/Form/Type/AdvancedModelType.php @@ -2,11 +2,14 @@ namespace BZIon\Form\Type; +use __; use BZIon\Form\Constraint\ValidModel; use BZIon\Form\Transformer\MultipleAdvancedModelTransformer; use BZIon\Form\Transformer\SingleAdvancedModelTransformer; use Doctrine\Common\Inflector\Inflector; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; @@ -19,6 +22,8 @@ class AdvancedModelType extends AbstractType */ private $types = array(); + private $options = array(); + /** * An object to always include * @var \Model|null @@ -33,11 +38,24 @@ class AdvancedModelType extends AbstractType /** * Create new ModelType + * * @param string|string[] $type The types of the model + * @param array $options */ - public function __construct($type) + public function __construct($type, $options = []) { - $this->types = (is_array($type)) ? $type : array($type); + $this->types = (is_array($type)) ? $type : [$type]; + + if (!is_array($type)) { + $options = [ + $type => $options, + ]; + } + + foreach ($this->types as $t) { + $this->options[strtolower($t)] = __::get($options, $t, []); + } + $this->types = array_map('strtolower', $this->types); } @@ -72,11 +90,12 @@ public function buildForm(FormBuilderInterface $builder, array $options) } // Model IDs that will be manipulated by javascript - $builder->add('ids', 'hidden', array( + $label = __::get(array_column(array_values($this->options), 'label'), 0, $builderName); + $builder->add('ids', HiddenType::class, array( 'attr' => array( 'class' => 'select2-compatible', 'data-exclude' => $exclude, - 'data-label' => $builderName, + 'data-label' => ($label === null) ? $builderName : $label, 'data-multiple' => $this->multiple, 'data-required' => $options['required'] ), @@ -88,17 +107,18 @@ public function buildForm(FormBuilderInterface $builder, array $options) $pluralType = ($this->multiple) ? Inflector::pluralize($type) : $type; $label = (count($this->types) > 1) ? "$builderName $pluralType" : $builderName; - $builder->add( - $builder->create($type, 'text', array( - 'attr' => array( - 'class' => 'model-select', - 'data-type' => $type, - 'placeholder' => $placeholder, - ), - 'label' => $label, - 'required' => false - )) - ); + $defaultOptions = [ + 'attr' => [ + 'class' => 'model-select', + 'data-type' => $type, + 'placeholder' => $placeholder, + ], + 'label' => $label, + 'required' => false, + ]; + $manualOptions = __::get($this->options, $type, []); + + $builder->add($type, TextType::class, __::merge($defaultOptions, $manualOptions)); } if ($this->multiple) { diff --git a/src/Form/Type/ModelType.php b/src/Form/Type/ModelType.php index be102e11..7a2eff30 100644 --- a/src/Form/Type/ModelType.php +++ b/src/Form/Type/ModelType.php @@ -44,7 +44,7 @@ class ModelType extends AbstractType */ public function __construct($type, $emptyElem = true, $modifier = null) { - $this->type = "$type"; + $this->type = $type; $this->emptyElem = $emptyElem; $this->modifier = $modifier; } @@ -123,7 +123,9 @@ public function configureOptions(OptionsResolver $resolver) private function getAll() { - $query = \Controller::getQueryBuilder($this->type); + $query = \Controller::getQueryBuilder($this->type); + $query->active(); + $modifier = $this->modifier; if ($modifier) { diff --git a/src/Model/AliasModel.php b/src/Model/AliasModel.php index 3f9a8db0..bb4dd715 100644 --- a/src/Model/AliasModel.php +++ b/src/Model/AliasModel.php @@ -50,7 +50,7 @@ public function getEscapedName() /** * Change the object's name * - * @return self + * @return static */ public function setName($name) { diff --git a/src/Model/BaseModel.php b/src/Model/BaseModel.php index 8e7878b9..b19ea331 100644 --- a/src/Model/BaseModel.php +++ b/src/Model/BaseModel.php @@ -38,6 +38,12 @@ abstract class BaseModel implements ModelInterface */ protected $status; + /** + * Whether or not this model has been soft deleted. + * @var bool + */ + protected $is_deleted; + /** * The database variable used for queries * @var Database @@ -52,9 +58,26 @@ abstract class BaseModel implements ModelInterface /** * The default status value for deletable models + * + * @deprecated 0.10.3 The `status` SET columns are deprecated. Using boolean columns is now the new standard. */ const DEFAULT_STATUS = 'active'; + /** + * The column name in the database that is used for marking a row as soft deleted. + */ + const DELETED_COLUMN = null; + + /** + * The value that's used in `self::DELETED_COLUMN` to mark something as soft deleted. + */ + const DELETED_VALUE = true; + + /** + * This model is handled directly by BZiON and is not editable by users. + */ + const SYSTEM_MODEL = false; + /** * The name of the database table used for queries * You can use this constant in static methods as such: @@ -155,8 +178,17 @@ public function update($name, $value) */ public function delete() { - $this->status = 'deleted'; - $this->update('status', 'deleted'); + if (static::DELETED_COLUMN === null) { + @trigger_error(sprintf('The %s class is using the deprecated `status` column and needs to be updated.', get_called_class()), E_USER_DEPRECATED); + + $this->status = 'deleted'; + $this->update('status', 'deleted'); + + return; + } + + $this->is_deleted = true; + $this->update('is_deleted', true); } /** @@ -214,17 +246,17 @@ protected static function chooseModelFromDatabase($id) */ protected static function fetchColumnValues($id) { - $table = static::TABLE; - $columns = static::getEagerColumns(); - - $results = Database::getInstance() - ->query("SELECT $columns FROM $table WHERE id = ? LIMIT 1", array($id)); + $qb = QueryBuilderFlex::createForTable(static::TABLE); + $results = $qb + ->select(static::getEagerColumnsList()) + ->find($id) + ; if (count($results) < 1) { return null; } - return $results[0]; + return $results; } /** @@ -334,7 +366,7 @@ protected static function fetchIdsFrom($column, $possible_values, $negate = fals /** * Get the MySQL columns that will be loaded as soon as the model is created * - * @todo Make this protected + * @deprecated 0.10.2 Replaced by static::getEagerColumnsList() in 0.11.0 * * @param string $prefix The prefix that'll be prefixed to column names * @@ -352,6 +384,8 @@ public static function getEagerColumns($prefix = null) * This is done in order to reduce the time needed to load parameters that * will not be requested (e.g player activation codes or permissions) * + * @deprecated 0.10.2 Replaced by static::getLazyColumnsList() in 0.11.0 + * * @return string|null The columns in a format readable by MySQL or null to * fetch no columns at all */ @@ -363,6 +397,8 @@ protected static function getLazyColumns() /** * Get a formatted string with a comma separated column list with table/alias prefixes if necessary. * + * @deprecated 0.10.2 This function has been removed and is no longer required with the new query builder + * * @param string|null $prefix The table name or SQL alias to be prepend to these columns * @param array $columns The columns to format * @@ -377,6 +413,78 @@ protected static function formatColumns($prefix = null, $columns = ['*']) return (($prefix . '.') . implode(sprintf(',%s.', $prefix), $columns)); } + // + // Query building for models + // + + /** + * Get a query builder instance for this model. + * + * @throws BadMethodCallException When this function has not been configured for a particular model + * @throws Exception When no database has been configured for BZiON + * + * @since 0.11.0 The expected return type has been changed from QueryBuilder to QueryBuilderFlex + * @since 0.9.0 + * + * @return QueryBuilderFlex + */ + public static function getQueryBuilder() + { + throw new BadMethodCallException(sprintf('No Query Builder has been configured for the %s model', get_called_class())); + } + + /** + * Modify a QueryBuilderFlex instance to configure what conditions should be applied when fetching "active" entries. + * + * This function is called whenever a QueryBuilder calls `static::active()`. A reference to the QueryBuilderFlex + * instance is passed to allow you to add any necessary conditions to the query. This function should return true if + * the QueryBuilderInstance has been modified to stop propagation to other modifications; return false if nothing + * has been modified. + * + * @internal For use of QueryBuilderFlex when fetching active entries. + * + * @param \QueryBuilderFlex $qb A reference to the QBF to allow modifications. + * + * @since 0.11.0 + * + * @return bool Returns true if propagation should stop and the QueryBuilderFlex should not make further modifications + * in this regard. + */ + public static function getActiveModels(QueryBuilderFlex &$qb) + { + return false; + } + + /** + * Get the list of columns that should be eagerly loaded when a model is created from database results. + * + * @since 0.11.0 + * + * @return array + */ + public static function getEagerColumnsList() + { + return ['*']; + } + + /** + * Get the list of columns that need to be lazily loaded in a model. + * + * This function should return an empty array if no columns need to be lazily loaded. + * + * @since 0.11.0 + * + * @return array + */ + public static function getLazyColumnsList() + { + return []; + } + + // + // Model creation from the database + // + /** * Load all the parameters of the model that were not loaded during the first * fetch from the database @@ -393,23 +501,30 @@ protected function assignLazyResult($result) * Load all the properties of the model that haven't been loaded yet * * @param bool $force Whether to force a reload - * @return self + * + * @throws \Pecee\Pixie\Exception + * @throws Exception + * + * @return static */ protected function lazyLoad($force = false) { if ((!$this->loaded || $force) && $this->valid) { $this->loaded = true; - $columns = $this->getLazyColumns(); + $columns = static::getLazyColumnsList(); - if ($columns !== null) { - $results = $this->db->query("SELECT $columns FROM {$this->table} WHERE id = ? LIMIT 1", array($this->id)); + if (!empty($columns)) { + $results = static::getQueryBuilder() + ->select($columns) + ->find($this->getId()) + ; - if (count($results) < 1) { + if (empty($results)) { throw new Exception("The model has mysteriously disappeared"); } - $this->assignLazyResult($results[0]); + $this->assignLazyResult($results); } else { $this->assignLazyResult(array()); } diff --git a/src/Model/Model.php b/src/Model/Model.php index fe177628..a934341f 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -27,7 +27,7 @@ public function __toString() */ public function isDeleted() { - if (!$this->isValid() || $this->getStatus() == 'deleted') { + if (!$this->isValid() || $this->is_deleted || $this->getStatus() == 'deleted') { return true; } @@ -41,16 +41,26 @@ public function isDeleted() */ public function isActive() { + if (static::DELETED_COLUMN !== null) { + return (!$this->is_deleted); + } + + @trigger_error(sprintf('Update the "%s" model to use the DELETED_* constants instead of the "status" column.', get_called_class()), E_USER_DEPRECATED); + return in_array($this->getStatus(), $this->getActiveStatuses()); } /** * Get the models's status * + * @deprecated 0.11.0 Use isDeleted() for checking for deleted models instead. + * * @return string */ public function getStatus() { + @trigger_error(sprintf('The "status" of models has been deprecated. Use %s::isDeleted() for checking for deleted models.', get_called_class()), E_USER_DEPRECATED); + if (!isset($this->status)) { $this->status = static::DEFAULT_STATUS; } @@ -208,6 +218,21 @@ public static function escape($string) return htmlspecialchars($string, ENT_QUOTES, 'UTF-8'); } + /** + * Create a single model object from a database result + * + * @param array $result The MySQL row of the model + * + * @return static + */ + public static function createFromDatabaseResult(&$result) + { + $model = new static($result['id'], $result); + $model->storeInCache(); + + return $model; + } + /** * Create model objects, given their MySQL entries * @@ -219,10 +244,7 @@ public static function createFromDatabaseResults(&$results) $models = array(); foreach ($results as $result) { - $model = new static($result['id'], $result); - $model->storeInCache(); - - $models[] = $model; + $models[] = self::createFromDatabaseResult($result); } return $models; diff --git a/src/QueryBuilder/MatchActivityQueryBuilder.php b/src/QueryBuilder/MatchActivityQueryBuilder.php index 2a46e7b3..5e153ae7 100644 --- a/src/QueryBuilder/MatchActivityQueryBuilder.php +++ b/src/QueryBuilder/MatchActivityQueryBuilder.php @@ -1,35 +1,57 @@ type; - $columns = $type::getEagerColumns($this->getFromAlias()); + $qb = self::createBuilder(); - $this->columns['activity'] = 'activity'; - $this->extraColumns = 'SUM(m2.activity) AS activity'; - $this->extras .= ' - LEFT JOIN - (SELECT - m.id,' - . implode(',', $selectColumns) . ', - TIMESTAMPDIFF(SECOND, timestamp, NOW()) / 86400 AS days_passed, - (0.0116687059537612 * (POW((45 - LEAST((SELECT days_passed), 45)), (1/6)) + ATAN(31 - (SELECT days_passed)) / 2)) AS activity - FROM - matches m' . - ($useMatchParticipationTable ? ' INNER JOIN match_participation mp ON m.id = mp.match_id ' : '') - . ' - WHERE - DATEDIFF(NOW(), timestamp) <= 45 - ORDER BY - timestamp DESC) m2 ON ' . $leftJoinOn + // The subquery to calculate each match's worth towards activity if it has occurred less than 45 days ago. + // - 86400 is in seconds; i.e. 24 hours + // - 0.0116687059537612 is a magic number + $matchActivityWorthQuery = $qb->table('matches')->alias('m'); + $matchActivityWorthQuery + ->select([ + 'm.id', + $qb->raw('TIMESTAMPDIFF(SECOND, `m`.`timestamp`, NOW()) / 86400 AS days_passed'), + $qb->raw('(0.0116687059537612 * (POW((45 - LEAST((SELECT days_passed), 45)), (1/6)) + ATAN(31 - (SELECT days_passed)) / 2)) AS activity'), + ]) + ->where($qb->raw('DATEDIFF(NOW(), `m`.`timestamp`) <= 45')) + ->orderBy('m.timestamp', 'DESC') ; - $this->groupQuery = 'GROUP BY ' . $columns; + return $matchActivityWorthQuery; + } + + /** + * @throws \Pecee\Pixie\Exception + * @throws Exception + */ + protected function buildMatchActivity(QueryBuilderFlex $subQuery, JoinBuilder $joinBuilder) + { + $qb = self::createBuilder(); + $type = $this->modelType; + + $this + ->select( + $qb->raw('SUM(m2.activity) AS activity') + ) + ->leftJoin( + $qb->subQuery($subQuery, 'm2'), + function (&$table) use ($joinBuilder) { + $table = $joinBuilder; + } + ) + ->groupBy($type::getEagerColumnsList()) + ; return $this; } diff --git a/src/QueryBuilder/PlayerQueryBuilder.php b/src/QueryBuilder/PlayerQueryBuilder.php index 9013ecf0..25131e33 100644 --- a/src/QueryBuilder/PlayerQueryBuilder.php +++ b/src/QueryBuilder/PlayerQueryBuilder.php @@ -1,13 +1,28 @@ includeMatchActivity( - ['mp.user_id'], - 'players.id = m2.user_id', - true - ); + $subQuery = $this->getMatchActivityWorthQuery(); + $subQuery + ->select([ + 'match_participation.user_id' + ]) + ->innerJoin('match_participation', 'm.id', '=', 'match_participation.match_id') + ; + + $join = new JoinBuilder(); + $join + ->on('players.id', '=', 'm2.user_id') + ; + + return $this->buildMatchActivity($subQuery, $join); } } diff --git a/src/QueryBuilder/QueryBuilderFlex.php b/src/QueryBuilder/QueryBuilderFlex.php new file mode 100644 index 00000000..45ff1e39 --- /dev/null +++ b/src/QueryBuilder/QueryBuilderFlex.php @@ -0,0 +1,439 @@ +table($tableName) + ; + } + + /** + * Creeate a QueryBuilder instance to work with a Model. + * + * @param string $modelType The FQN for the model that + * + * @throws Exception If there is no database connection configured. + * + * @return static + */ + final public static function createForModel(string $modelType) + { + return self::createBuilder() + ->table(constant("$modelType::TABLE")) + ->setModelType($modelType) + ; + } + + // + // Overridden QueryBuilder Functions + // + + /** + * {@inheritdoc} + */ + public function __construct(Connection $connection = null) + { + parent::__construct($connection); + + $this->setFetchMode(PDO::FETCH_ASSOC); + } + + /** + * {@inheritdoc} + * + * @internal Use one of the QueryBuilderFlex get*() methods instead. + * + * @see self::getArray() + * @see self::getModels() + * @see self::getNames() + */ + public function get(): array + { + $queryObject = $this->getQuery(); + $debug = new DatabaseQuery($queryObject->getSql(), $queryObject->getBindings()); + + /** @var array $results */ + $results = parent::get(); + + $debug->finish($results); + + foreach ($results as &$result) { + $result = array_merge($this->injectedValues, $result); + } + + return $results; + } + + /** + * {@inheritdoc} + * + * @return static + */ + public function limit($limit): IQueryBuilderHandler + { + $this->resultsPerPage = $limit; + + return parent::limit($limit); + } + + /** + * {@inheritdoc} + */ + protected function whereHandler($key, string $operator = null, $value = null, $joiner = 'AND'): IQueryBuilderHandler + { + // For certain type of objects, we convert them into something the query builder can handle correctly + if ($value instanceof BaseModel) { + $value = $value->getId(); + } + elseif ($value instanceof DateTime) { + $value = (string)$value; + } + + return parent::whereHandler($key, $operator, $value, $joiner); + } + + // + // QueryBuilderFlex unique functions + // + + /** + * Request that only non-deleted Models should be returned. + * + * @return static + */ + public function active(): QueryBuilderFlex + { + $type = $this->modelType; + + // Since it's a system model, values are always handled by BZiON core meaning there will always only be "active" + // values in the database. + if ($type::SYSTEM_MODEL) { + return $this; + } + + $column = $type::DELETED_COLUMN; + + if ($column === null) { + @trigger_error( + sprintf('The use of the status column is deprecated. Update the %s model to use the DELETED_* constants.', get_called_class()), + E_USER_DEPRECATED + ); + + return $this->whereIn('status', $type::getActiveStatuses()); + } + + $stopPropagation = $type::getActiveModels($this); + + if ($stopPropagation) { + return $this; + } + + return $this->whereNot($column, '=', $type::DELETED_VALUE); + } + + /** + * An alias for QueryBuilder::getModels(), with fast fetching on by default and no return of results. + * + * @param bool $fastFetch Whether to perform one query to load all the model data instead of fetching them one by + * one + * + * @throws \Pecee\Pixie\Exception + * + * @return void + */ + public function addToCache(bool $fastFetch = true): void + { + $this->getModels($fastFetch); + } + + /** + * Get the amount of pages this query would have. + * + * @throws \Pecee\Pixie\Exception + * + * @return int + */ + public function countPages(): int + { + return (int)ceil($this->count() / $this->resultsPerPage); + } + + /** + * Request that a specific model is not returned. + * + * @param Model|int $model The ID or model you don't want to get + * + * @return static + */ + public function except($model): QueryBuilderFlex + { + if ($model instanceof Model) { + $model = $model->getId(); + } + + $this->whereNot('id', '=', $model); + + return $this; + } + + /** + * Find the first matching model in the database or return an invalid model. + * + * @param mixed $value The value to search for + * @param string $columnName The column name we'll be checking + * + * @throws \Pecee\Pixie\Exception + * + * @return Model + */ + public function findModel($value, string $columnName = 'id'): Model + { + $type = $this->modelType; + + /** @var array $result */ + $result = parent::find($value, $columnName); + + if ($result === null) { + return $type::get(0); + } + + return $type::createFromDatabaseResult($result); + } + + /** + * Only show results from a specific page. + * + * This method will automatically take care of the calculations for a correct OFFSET. + * + * @param int|null $page The page number (or null to show all pages - counting starts from 0) + * + * @throws \Pecee\Pixie\Exception + * + * @return static + */ + public function fromPage(int $page = null): QueryBuilderFlex + { + if ($page === null) { + $this->offset($page); + + return $this; + } + + $page = intval($page); + $page = ($page <= 0) ? 1 : $page; + + $this->offset((min($page, $this->countPages()) - 1) * $this->resultsPerPage); + + return $this; + } + + /** + * Get the results of query as an array. + * + * @param array|string $columns + * + * @throws \Pecee\Pixie\Exception + * + * @return array + */ + public function getArray($columns): array + { + $this->select($columns); + + return $this->get(); + } + + /** + * Perform the query and get the results as Models. + * + * @param bool $fastFetch Whether to perform one query to load all the model data instead of fetching them one by + * one (ignores cache) + * + * @throws \Pecee\Pixie\Exception + * + * @return Model[] + */ + public function getModels(bool $fastFetch = true): array + { + /** @var Model $type */ + $type = $this->modelType; + + $modelColumnsToSelect = $type::getEagerColumnsList(); + + if (isset($this->statements['joins'])) { + $modelColumnsToSelect = __::mapValues($modelColumnsToSelect, function ($value, $key, $array) use ($type) { + return sprintf('%s.%s', $type::TABLE, $value); + }); + } + + $this->select($modelColumnsToSelect); + + $results = $this->get(); + + if ($fastFetch) { + return $type::createFromDatabaseResults($results); + } + + return $type::arrayIdToModel(array_column($results, 'id')); + } + + /** + * Perform the query and get back the results in an array of names. + * + * @throws \Pecee\Pixie\Exception + * @throws UnexpectedValueException When no name column has been specified + * + * @return string[] An array of the type $id => $name + */ + public function getNames(): array + { + if (!$this->modelNameColumn) { + throw new UnexpectedValueException(sprintf('The name column has not been specified for this query builder. Use %s::setNameColumn().', get_called_class())); + } + + $this->select(['id', $this->modelNameColumn]); + + $results = $this->get(); + + return array_column($results, $this->modelNameColumn, 'id'); + } + + /** + * Inject variables into the returned database results. + * + * These values will be merged in with values returned from database results. Database results will override any + * injected values. + * + * @param array $injection + * + * @return QueryBuilderFlex + */ + public function injectResultValues(array $injection): QueryBuilderFlex + { + $this->injectedValues = $injection; + + return $this; + } + + /** + * Set the model this QueryBuilder will be working this. + * + * This information is used for automatically retrieving table names, eager columns, and lazy columns for these + * models. + * + * @param string $modelType The FQN of the model this QueryBuilder will be working with + * + * @return $this + */ + public function setModelType(string $modelType = null): QueryBuilderFlex + { + $this->modelType = $modelType; + + return $this; + } + + /** + * Set the column that'll be used as the human-friendly name of the model. + * + * @param string $columnName + * + * @return static + */ + public function setNameColumn(string $columnName): QueryBuilderFlex + { + if (!is_subclass_of($this->modelType, NamedModel::class)) { + throw new LogicException(sprintf('Setting name columns is only supported in models implementing the "%s" interface.', NamedModel::class)); + } + + $this->modelNameColumn = $columnName; + + return $this; + } + + /** + * Make sure that Models invisible to a player are not returned. + * + * Note that this method does not take PermissionModel::canBeSeenBy() into + * consideration for performance purposes, so you will have to override this + * in your query builder if necessary. + * + * @param Player $player The player in question + * @param bool $showDeleted Use false to hide deleted models even from admins + * + * @return static + */ + public function visibleTo(Player $player, bool $showDeleted = false): QueryBuilderFlex + { + $type = $this->modelType; + + if (is_subclass_of($this->modelType, PermissionModel::class) && + $player->hasPermission(constant("$type::EDIT_PERMISSION")) + ) { + // The player is an admin who can see the hidden models + if (!$showDeleted) { + $col = constant("$type::DELETED_COLUMN"); + + if ($col !== null) { + $this->whereNot($col, '=', constant("$type::DELETED_VALUE")); + } + } + } else { + return $this->active(); + } + + return $this; + } +} diff --git a/src/QueryBuilder/TeamQueryBuilder.php b/src/QueryBuilder/TeamQueryBuilder.php index 6886f43a..900e4eff 100644 --- a/src/QueryBuilder/TeamQueryBuilder.php +++ b/src/QueryBuilder/TeamQueryBuilder.php @@ -1,12 +1,31 @@ includeMatchActivity( - ['m.team_a', 'm.team_b'], - 'teams.id = m2.team_a OR teams.id = m2.team_b' - ); + $subQuery = $this->getMatchActivityWorthQuery(); + $subQuery + ->select([ + 'm.team_a', + 'm.team_b', + ]) + ; + + $join = new JoinBuilder(); + $join + ->on('teams.id', '=', 'm2.team_a') + ->orOn('teams.id', '=', 'm2.team_b') + ; + + return $this->buildMatchActivity($subQuery, $join); } } diff --git a/src/QueryBuilder/VisitQueryBuilder.php b/src/QueryBuilder/VisitQueryBuilder.php index 9b773d6a..42b0840e 100644 --- a/src/QueryBuilder/VisitQueryBuilder.php +++ b/src/QueryBuilder/VisitQueryBuilder.php @@ -12,21 +12,22 @@ * * @package BZiON\Models\QueryBuilder */ -class VisitQueryBuilder extends QueryBuilder +class VisitQueryBuilder extends QueryBuilderFlex { /** * Search for a visit from the given IP or Host * * @param string $query An IP or host to search - * @return self + * + * @return static */ public function search($query) { - $this->whereConditions[] = "(ip LIKE CONCAT('%', ?, '%') OR host LIKE CONCAT('%', ?, '%'))"; - - $this->parameters[] = $query; - $this->parameters[] = $query; -// $this->parameters[] = $query; + $this->where(function ($qb) use ($query) { + /** @var static $qb */ + $qb->where('ip', 'LIKE', "%$query%"); + $qb->orWhere('host', 'LIKE', "%$query%"); + }); return $this; } diff --git a/src/Service.php b/src/Service.php index 84cb326d..08357f1c 100644 --- a/src/Service.php +++ b/src/Service.php @@ -7,6 +7,7 @@ */ use BZIon\Cache\ModelCache; +use Pecee\Pixie\Connection; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Form\FormFactory; @@ -56,6 +57,23 @@ abstract class Service */ private static $kernel; + private static $qbConnection; + private static $qbConfig; + + public static function setQueryBuilderConfig(array $config) + { + self::$qbConfig = $config; + } + + public static function getQueryBuilderConnection() + { + if (!self::$qbConnection) { + self::$qbConnection = new Connection('mysql', self::$qbConfig); + } + + return self::$qbConnection; + } + /** * @param Request $request */ diff --git a/src/Twig/AppGlobal.php b/src/Twig/AppGlobal.php index fd03bd65..bf18e268 100644 --- a/src/Twig/AppGlobal.php +++ b/src/Twig/AppGlobal.php @@ -191,13 +191,17 @@ public function getSocket() /** * Get a list of visible pages * + * @throws \Pixie\Exception + * * @return \Page[] */ public function getPages() { return \Page::getQueryBuilder() - ->where('status')->equals('live') - ->getModels($fast = true); + ->active() + ->whereNot('is_unlisted', '=', true) + ->getModels($fast = true) + ; } /** diff --git a/tests/ModelTests/MatchEloTest.php b/tests/ModelTests/MatchEloTest.php index bb88489e..68fccc59 100644 --- a/tests/ModelTests/MatchEloTest.php +++ b/tests/ModelTests/MatchEloTest.php @@ -6,27 +6,6 @@ class MatchEloTest extends TestCase const TEAM_WIN = 1; const TEAM_DRAW = 2; - /** @var \Faker\Generator */ - private $faker; - private $createdModels = []; - - protected function setUp() - { - $this->connectToDatabase(); - - $this->faker = Faker\Factory::create(); - } - - public function tearDown() - { - foreach ($this->createdModels as $model) - { - $this->wipe($model); - } - - parent::tearDown(); - } - // // Yes, we need to test our own helper function in our tests // diff --git a/tests/ModelTests/NewsCategoryTest.php b/tests/ModelTests/NewsCategoryTest.php index bf6c286b..ddeb0ce9 100644 --- a/tests/ModelTests/NewsCategoryTest.php +++ b/tests/ModelTests/NewsCategoryTest.php @@ -19,22 +19,28 @@ class NewsTest extends TestCase protected function setUp() { - $this->connectToDatabase(); + parent::setUp(); $this->player_with_create_perms = $this->getNewPlayer(); $this->player_without_create_perms = $this->getNewPlayer(); $this->player_with_create_perms->addRole(Role::ADMINISTRATOR); - $this->newsCategory = NewsCategory::addCategory("Sample Category"); + $this->newsCategory = NewsCategory::addCategory('Sample Category'); + } + + public function tearDown() + { + parent::tearDown(); + + $this->wipe($this->newsCategory); } public function testCustomNewsCategorySetup() { - $this->assertInstanceOf('NewsCategory', $this->newsCategory); - $this->assertEquals(array("enabled"), $this->newsCategory->getActiveStatuses()); - $this->assertEquals("category", $this->newsCategory->getParamName()); - $this->assertEquals("news category", $this->newsCategory->getTypeForHumans()); + $this->assertInstanceOf(NewsCategory::class, $this->newsCategory); + $this->assertEquals('category', $this->newsCategory->getParamName()); + $this->assertEquals('news category', $this->newsCategory->getTypeForHumans()); } public function testCustomNewsCategoryExists() @@ -47,79 +53,40 @@ public function testCustomNewsCategoryIsNotProtected() $this->assertFalse($this->newsCategory->isProtected()); } - public function testCustomNewsCategoryDefaultStatus() - { - $this->assertEquals("enabled", $this->newsCategory->getStatus()); - } - - public function testDisablingCustomNewsCategory() - { - $this->newsCategory->disableCategory(); - $this->assertEquals("disabled", $this->newsCategory->getStatus()); - } - - public function testDisablingDisabledCustomNewsCategory() + public function testCustomNewsCategoryIsNotDeletedByDefault() { - $this->newsCategory->disableCategory(); - $this->assertEquals("disabled", $this->newsCategory->getStatus()); - - $this->newsCategory->disableCategory(); - $this->assertEquals("disabled", $this->newsCategory->getStatus()); + $this->assertFalse($this->newsCategory->isDeleted()); } - public function testEnableDisabledCustomNewsCategory() + public function testCustomNewsCategoryIsNotReadOnlyByDefault() { - $this->newsCategory->disableCategory(); - $this->assertEquals("disabled", $this->newsCategory->getStatus()); - - $this->newsCategory->enableCategory(); - $this->assertEquals("enabled", $this->newsCategory->getStatus()); + $this->assertFalse($this->newsCategory->isReadOnly()); } public function testDeletingProtectedNewsCategory() { - $newsCategory = NewsCategory::get(1); + $this->expectException(DeletionDeniedException::class); + $newsCategory = NewsCategory::get(1); $newsCategory->delete(); - $this->assertEquals('enabled', $newsCategory->getStatus()); - - $this->assertArrayContainsModel($newsCategory, NewsCategory::getCategories()); } public function testDeletingCustomNewsCategoryWithoutPosts() { $this->newsCategory->delete(); - $this->assertEquals('deleted', $this->newsCategory->getStatus()); + $this->assertTrue($this->newsCategory->isDeleted()); $this->assertArrayDoesNotContainModel($this->newsCategory, NewsCategory::getCategories()); } public function testDeletingCustomNewsCategoryWithPosts() { - $news = News::addNews(StringMocks::SampleTitleOne, StringMocks::LargeContent, $this->player_with_create_perms->getId(), $this->newsCategory->getId()); + $this->expectException(DeletionDeniedException::class); + + $this->createdModels[] = News::addNews(StringMocks::SampleTitleOne, StringMocks::LargeContent, $this->player_with_create_perms->getId(), $this->newsCategory->getId()); $this->assertArrayLengthEquals($this->newsCategory->getNews(), 1); $this->newsCategory->delete(); - $this->assertEquals('enabled', $this->newsCategory->getStatus()); - - $this->assertArrayContainsModel($this->newsCategory, NewsCategory::getCategories()); - - $this->wipe($news); - } - - public function testCustomNewsCategoryQueryBuilder() - { - $qb = $this->newsCategory->getQueryBuilder(); - $this->assertInstanceOf('QueryBuilder', $qb); - - $results = $qb->where("status")->equals("enabled")->getModels(); - $this->assertArrayLengthEquals($results, 2); - } - - public function tearDown() - { - $this->wipe($this->newsCategory); - parent::tearDown(); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 73861dfd..0406c1f1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,6 +4,10 @@ abstract class TestCase extends \PHPUnit_Framework_TestCase { + /** @var \Faker\Generator */ + protected $faker; + protected $createdModels = []; + /** * The BZID of the last player created, used to prevent conflicts when creating new players * @var int @@ -200,11 +204,22 @@ protected function reset() } } + protected function setUp() + { + self::connectToDatabase(); + + $this->faker = Faker\Factory::create(); + } + /** * Clean-up all the database entries added during the test */ public function tearDown() { + foreach ($this->createdModels as $model) { + $this->wipe($model); + } + foreach ($this->playersCreated as $id) { self::wipe(Player::get($id)); } diff --git a/views/Invitation/invite.html.twig b/views/Invitation/invite.html.twig new file mode 100644 index 00000000..6b26b994 --- /dev/null +++ b/views/Invitation/invite.html.twig @@ -0,0 +1,35 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Inviting {{ player.name }} to {{ team.name }}{% endblock %} + +{% block pageTitle %} +Are you sure you would like to invite {{ player.name }} to {{ team.name }}?
+ + {{ form_start(form) }} + {{ form_row(form.invited_player, { attr: { class: 'mb3' } }) }} + {{ form_row(form.message, { attr: { class: 'mb3' } }) }} + +