Add vouchers

Signed-off-by: Celestora <kitsuruko@gmail.com>
This commit is contained in:
Celestora 2021-10-07 11:47:30 +03:00
parent 49f9d2b61b
commit 255d70e974
13 changed files with 393 additions and 18 deletions

View file

@ -219,6 +219,11 @@ class User extends RowModel
return $this->getRecord()->coins;
}
function getRating(): int
{
return $this->getRecord()->rating;
}
function getReputation(): int
{
return $this->getRecord()->reputation;
@ -393,7 +398,8 @@ class User extends RowModel
}
return (object) [
"total" => 100 - $incompleteness,
"total" => 100 - $incompleteness + $this->getRating(),
"percent" => min(100 - $incompleteness + $this->getRating(), 100),
"unfilled" => $unfilled,
];
}

View file

@ -0,0 +1,85 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Entities;
use Chandler\Database\DatabaseConnection as DB;
use openvk\Web\Models\Repositories\Users;
use openvk\Web\Models\RowModel;
class Voucher extends RowModel
{
protected $tableName = "coin_vouchers";
function getCoins(): int
{
return $this->getRecord()->coins;
}
function getRating(): int
{
return $this->getRecord()->rating;
}
function getToken(): string
{
return $this->getRecord()->token;
}
function getFormattedToken(): string
{
$fmtTok = "";
$token = $this->getRecord()->token;
foreach(array_chunk(str_split($token), 6) as $chunk)
$fmtTok .= implode("", $chunk) . "-";
return substr($fmtTok, 0, -1);
}
function getRemainingUsages(): float
{
return (float) ($this->getRecord()->usages_left ?? INF);
}
function getUsers(int $page = -1, ?int $perPage = NULL): \Traversable
{
$relations = $this->getRecord()->related("voucher_users.voucher");
if($page !== -1)
$relations = $relations->page($page, $perPage ?? OPENVK_DEFAULT_PER_PAGE);
foreach($relations as $relation)
yield (new Users)->get($relation->user);
}
function isExpired(): bool
{
return $this->getRemainingUsages() < 1;
}
function wasUsedBy(User $user): bool
{
$record = $this->getRecord()->related("voucher_users.voucher")->where("user", $user->getId());
return sizeof($record) > 0;
}
function willUse(User $user): bool
{
if($this->wasUsedBy($user))
return false;
if($this->isExpired())
return false;
$this->setRemainingUsages($this->getRemainingUsages() - 1);
DB::i()->getContext()->table("voucher_users")->insert([
"voucher" => $this->getId(),
"user" => $user->getId(),
]);
return true;
}
function setRemainingUsages(float $usages): void
{
$this->stateChanges("usages_left", $usages === INF ? NULL : ((int) $usages));
$this->save();
}
}

View file

@ -6,8 +6,8 @@ use Nette\Database\Table\ActiveRow;
abstract class Repository
{
private $context;
private $table;
protected $context;
protected $table;
protected $tableName;
protected $modelName;
@ -29,5 +29,18 @@ abstract class Repository
return $this->toEntity($this->table->get($id));
}
function size(bool $withDeleted = false): int
{
return sizeof($this->table->where("deleted", $withDeleted));
}
function enumerate(int $page, ?int $perPage = NULL, bool $withDeleted = false): \Traversable
{
$perPage ??= OPENVK_DEFAULT_PER_PAGE;
foreach($this->table->where("deleted", $withDeleted)->page($page, $perPage) as $entity)
yield $this->toEntity($entity);
}
use \Nette\SmartObject;
}

View file

@ -0,0 +1,19 @@
<?php declare(strict_types=1);
namespace openvk\Web\Models\Repositories;
use openvk\Web\Models\Entities\Voucher;
class Vouchers extends Repository
{
protected $tableName = "coin_vouchers";
protected $modelName = "Voucher";
function getByToken(string $token, bool $withDeleted = false)
{
$voucher = $this->table->where([
"token" => $token,
"deleted" => $withDeleted,
])->fetch();
return $this->toEntity($voucher);
}
}

View file

@ -1,17 +1,19 @@
<?php declare(strict_types=1);
namespace openvk\Web\Presenters;
use openvk\Web\Models\Entities\User;
use openvk\Web\Models\Repositories\{Users, Clubs};
use openvk\Web\Models\Entities\{Voucher, User};
use openvk\Web\Models\Repositories\{Users, Clubs, Vouchers};
final class AdminPresenter extends OpenVKPresenter
{
private $users;
private $clubs;
private $vouchers;
function __construct(Users $users, Clubs $clubs)
function __construct(Users $users, Clubs $clubs, Vouchers $vouchers)
{
$this->users = $users;
$this->clubs = $clubs;
$this->users = $users;
$this->clubs = $clubs;
$this->vouchers = $vouchers;
parent::__construct();
}
@ -106,6 +108,57 @@ final class AdminPresenter extends OpenVKPresenter
}
}
function renderVouchers(): void
{
$this->template->count = $this->vouchers->size();
$this->template->vouchers = iterator_to_array($this->vouchers->enumerate((int) ($this->queryParam("p") ?? 1)));
}
function renderVoucher(int $id): void
{
$voucher = NULL;
$this->template->form = (object) [];
if($id === 0) {
$this->template->form->id = 0;
$this->template->form->token = NULL;
$this->template->form->coins = 0;
$this->template->form->rating = 0;
$this->template->form->usages = -1;
$this->template->form->users = [];
} else {
$voucher = $this->vouchers->get($id);
if(!$voucher)
$this->notFound();
$this->template->form->id = $voucher->getId();
$this->template->form->token = $voucher->getToken();
$this->template->form->coins = $voucher->getCoins();
$this->template->form->rating = $voucher->getRating();
$this->template->form->usages = $voucher->getRemainingUsages();
$this->template->form->users = iterator_to_array($voucher->getUsers());
if($this->template->form->usages === INF)
$this->template->form->usages = -1;
else
$this->template->form->usages = (int) $this->template->form->usages;
}
if($_SERVER["REQUEST_METHOD"] !== "POST")
return;
$voucher ??= new Voucher;
$voucher->setCoins((int) $this->postParam("coins"));
$voucher->setRating((int) $this->postParam("rating"));
$voucher->setRemainingUsages($this->postParam("usages") === '-1' ? INF : ((int) $this->postParam("usages")));
if(!empty($tok = $this->postParam("token")) && strlen($tok) === 24)
$voucher->setToken($tok);
$voucher->save();
$this->redirect("/admin/vouchers/id" . $voucher->getId(), static::REDIRECT_TEMPORARY);
exit;
}
function renderFiles(): void
{

View file

@ -7,6 +7,7 @@ use openvk\Web\Models\Repositories\Users;
use openvk\Web\Models\Repositories\Albums;
use openvk\Web\Models\Repositories\Videos;
use openvk\Web\Models\Repositories\Notes;
use openvk\Web\Models\Repositories\Vouchers;
final class UserPresenter extends OpenVKPresenter
{
@ -240,7 +241,7 @@ final class UserPresenter extends OpenVKPresenter
if(!$user->setShortCode(empty($this->postParam("sc")) ? NULL : $this->postParam("sc")))
$this->flashFail("err", tr("error"), tr("error_shorturl_incorrect"));
}elseif($_GET['act'] === "privacy") {
} else if($_GET['act'] === "privacy") {
$settings = [
"page.read",
"page.info.read",
@ -256,7 +257,22 @@ final class UserPresenter extends OpenVKPresenter
$input = $this->postParam(str_replace(".", "_", $setting));
$user->setPrivacySetting($setting, min(3, abs($input ?? $user->getPrivacySetting($setting))));
}
}elseif($_GET['act'] === "interface") {
} else if($_GET['act'] === "finance.top-up") {
$token = $this->postParam("key0") . $this->postParam("key1") . $this->postParam("key2") . $this->postParam("key3");
$voucher = (new Vouchers)->getByToken($token);
if(!$voucher)
$this->flashFail("err", tr("invalid_voucher"), tr("voucher_bad"));
$perm = $voucher->willUse($user);
if(!$perm)
$this->flashFail("err", tr("invalid_voucher"), tr("voucher_bad"));
$user->setCoins($user->getCoins() + $voucher->getCoins());
$user->setRating($user->getRating() + $voucher->getRating());
$user->save();
$this->flashFail("succ", tr("voucher_good"), tr("voucher_redeemed"));
} else if($_GET['act'] === "interface") {
if (isset(Themepacks::i()[$this->postParam("style")]) || $this->postParam("style") === Themepacks::DEFAULT_THEME_ID)
$user->setStyle($this->postParam("style"));
@ -271,7 +287,7 @@ final class UserPresenter extends OpenVKPresenter
if(in_array($this->postParam("nsfw"), [0, 1, 2]))
$user->setNsfwTolerance((int) $this->postParam("nsfw"));
}elseif($_GET['act'] === "lMenu") {
} else if($_GET['act'] === "lMenu") {
$settings = [
"menu_bildoj" => "photos",
"menu_filmetoj" => "videos",
@ -300,7 +316,7 @@ final class UserPresenter extends OpenVKPresenter
);
}
$this->template->mode = in_array($this->queryParam("act"), [
"main", "privacy", "finance", "interface"
"main", "privacy", "finance", "finance.top-up", "interface"
]) ? $this->queryParam("act")
: "main";
$this->template->user = $user;

View file

@ -69,9 +69,14 @@
Группы
</a>
</li>
</ul>
<div class="aui-nav-heading">
<strong>Платные услуги</strong>
</div>
<ul class="aui-nav">
<li>
<a href="/admin/files">
Загруженные файлы
<a href="/admin/vouchers">
{_vouchers}
</a>
</li>
</ul>
@ -141,5 +146,9 @@
</section>
</footer>
</div>
{script "js/node_modules/jquery/dist/jquery.min.js"}
{script "js/node_modules/@atlassian/aui/dist/aui/aui-prototyping.js"}
<script>AJS.tabs.setup();</script>
</body>
</html>

View file

@ -0,0 +1,92 @@
{extends "@layout.xml"}
{block title}
{_edit}
{/block}
{block heading}
{_edit} №{$form->token ?? "undefined"}
{/block}
{block content}
<div style="margin: 8px -8px;" class="aui-tabs horizontal-tabs">
<ul class="tabs-menu">
<li class="menu-item active-tab">
<a href="#info">Информация</a>
</li>
<li class="menu-item">
<a href="#activators">{_voucher_activators}</a>
</li>
</ul>
<div class="tabs-pane active-pane" id="info">
<form class="aui" method="POST">
<div class="field-group">
<label for="id">
ID
</label>
<input class="text long-field" type="number" id="id" name="id" disabled value="{$form->id}" />
</div>
<div class="field-group">
<label for="token">
Серийный номер
</label>
<input class="text long-field" type="text" id="token" name="token" value="{$form->token}" />
<div class="description">Номер состоит из 24 символов, если формат неправильный или поле не заполнено, будет назначен автоматически.</div>
</div>
<div class="field-group">
<label for="coins">
Количество голосов
</label>
<input class="text long-field" type="number" min="0" id="coins" name="coins" value="{$form->coins}" />
</div>
<div class="field-group">
<label for="rating">
Количество рейтинга
</label>
<input class="text long-field" type="number" min="0" id="rating" name="rating" value="{$form->rating}" />
</div>
<div class="field-group">
<label for="usages">
{if $form->id === 0}
{_usages_total}
{else}
{_usages_left}
{/if}
</label>
<input class="text long-field" type="number" min="-1" id="usages" name="usages" value="{$form->usages}" />
<div class="description">Количество аккаунтов, которые могут использовать ваучер. Если написать -1, будет Infinity.</div>
</div>
<div class="buttons-container">
<div class="buttons">
<input type="hidden" name="hash" value="{$csrfToken}" />
<input class="aui-button aui-button-primary submit" type="submit" value="{_save}">
</div>
</div>
</form>
</div>
<div class="tabs-pane" id="activators">
<table rules="none" class="aui aui-table-list">
<tbody>
<tr n:foreach="$form->users as $user">
<td>
<span class="aui-avatar aui-avatar-xsmall">
<span class="aui-avatar-inner">
<img src="{$user->getAvatarUrl()}" alt="{$user->getCanonicalName()}" role="presentation" />
</span>
</span>
<a href="{$user->getURL()}">{$user->getCanonicalName()}</a>
<span n:if="$user->isBanned()" class="aui-lozenge aui-lozenge-subtle aui-lozenge-removed">
заблокирован
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/block}

View file

@ -0,0 +1,61 @@
{extends "@layout.xml"}
{block title}
{_vouchers}
{/block}
{block heading}
{_vouchers}
{/block}
{block content}
<table class="aui aui-table-list">
<thead>
<tr>
<th>#</th>
<th>Серийный номер</th>
<th>Голоса</th>
<th>Рейгтинг</th>
<th>Осталось использований</th>
<th>Состояние</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr n:foreach="$vouchers as $voucher">
<td>{$voucher->getId()}</td>
<td>{$voucher->getFormattedToken()}</td>
<td>{$voucher->getCoins()}&#xa2;</td>
<td>{$voucher->getRating()}</td>
<td>{$voucher->getRemainingUsages() === INF ? "∞" : $voucher->getRemainingUsages()}</td>
<td>
{if $voucher->isExpired()}
<span class="aui-lozenge aui-lozenge-subtle aui-lozenge-removed">закончился</span>
{else}
<span class="aui-lozenge aui-lozenge-subtle aui-lozenge-success">активен</span>
{/if}
</td>
<td>
<a class="aui-button aui-button-primary" href="/admin/vouchers/id{$voucher->getId()}">
<span class="aui-icon aui-icon-small aui-iconfont-new-edit">Редактировать</span>
</a>
</td>
</tr>
</tbody>
</table>
<br/>
<div align="right">
<a style="float: left;" class="aui-button aui-button-primary" href="/admin/vouchers/id0">
{_create}
</a>
{var isLast = ((20 * (($_GET['p'] ?? 1) - 1)) + sizeof($vouchers)) < $count}
<a n:if="($_GET['p'] ?? 1) > 1" class="aui-button" href="?p={($_GET['p'] ?? 1) - 1}">
⭁ туда
</a>
<a n:if="$isLast" class="aui-button" href="?p={($_GET['p'] ?? 1) + 1}">
⭇ сюда
</a>
</div>
{/block}

View file

@ -10,6 +10,7 @@
{var isMain = $mode === 'main'}
{var isPrivacy = $mode === 'privacy'}
{var isFinance = $mode === 'finance'}
{var isFinanceTU = $mode === 'finance.top-up'}
{var isInterface = $mode === 'interface'}
<div class="tabs">
@ -19,8 +20,8 @@
<div n:attr="id => ($isPrivacy ? 'activetabs' : 'ki')" class="tab">
<a n:attr="id => ($isPrivacy ? 'act_tab_a' : 'ki')" href="/settings?act=privacy">{_"privacy"}</a>
</div>
<div n:attr="id => ($isFinance ? 'activetabs' : 'ki')" class="tab">
<a n:attr="id => ($isFinance ? 'act_tab_a' : 'ki')" href="/settings?act=finance">{_points}</a>
<div n:attr="id => (($isFinance || $isFinanceTU) ? 'activetabs' : 'ki')" class="tab">
<a n:attr="id => (($isFinance || $isFinanceTU) ? 'act_tab_a' : 'ki')" href="/settings?act=finance">{_points}</a>
</div>
<div n:attr="id => ($isInterface ? 'activetabs' : 'ki')" class="tab">
<a n:attr="id => ($isInterface ? 'act_tab_a' : 'ki')" href="/settings?act=interface">{_"interface"}</a>
@ -265,11 +266,26 @@
<b>
{_on_your_account}<br/>
<span style="font-size: 50px;">{$thisUser->getCoins()}</span><br/>
{_points_count}
{_points_count}<br/><br/>
<small><a href="?act=finance.top-up">[{_have_voucher}?]</a></small>
</b>
</p>
</div>
{elseif $isFinanceTU}
<p>{_voucher_explanation} {_voucher_explanation_ex}</p>
<form action="/settings?act=finance.top-up" method="POST" enctype="multipart/form-data">
<input type="text" name="key0" size="6" placeholder="123456" required="required" style="display: inline-block; width: 50px; text-align: center;" /> -
<input type="text" name="key1" size="6" placeholder="789012" required="required" style="display: inline-block; width: 50px; text-align: center;" /> -
<input type="text" name="key2" size="6" placeholder="345678" required="required" style="display: inline-block; width: 50px; text-align: center;" /> -
<input type="text" name="key3" size="6" placeholder="90ABCD" required="required" style="display: inline-block; width: 50px; text-align: center;" />
<br/><br/>
<input type="hidden" name="hash" value="{$csrfToken}" />
<input type="submit" value="{_redeem}" class="button" />
</form>
{elseif $isInterface}
<h4>{_ui_settings_interface}</h4>

View file

@ -121,7 +121,7 @@
{var completeness = $user->getProfileCompletenessReport()}
<div class="completeness-gauge">
<div style="width: {$completeness->total}%"></div>
<div style="width: {$completeness->percent}%"></div>
<span>{$completeness->total}%</span>
</div>

View file

@ -33,4 +33,5 @@ services:
- openvk\Web\Models\Repositories\Notifications
- openvk\Web\Models\Repositories\TicketComments
- openvk\Web\Models\Repositories\IPs
- openvk\Web\Models\Repositories\Vouchers
- openvk\Web\Models\Repositories\ContentSearchRepository

View file

@ -209,6 +209,10 @@ routes:
handler: "Admin->clubs"
- url: "/admin/clubs/id{num}"
handler: "Admin->club"
- url: "/admin/vouchers"
handler: "Admin->vouchers"
- url: "/admin/vouchers/id{num}"
handler: "Admin->voucher"
- url: "/admin/ban.pl/{num}"
handler: "Admin->quickBan"
- url: "/admin/warn.pl/{num}"