[IMP] website: add the possibility to share coupon and promotion links
Coupons and promotion programs can now be shared by creating a link that can directly be used by the customer. Using the link, the system will try to apply the coupon/promotion to the customer's order. If this can't be done, the error will be shown to the user, for instance "A minimum amount of 1000$ is required to get the free ipad". That coupon/promotion will then be stored in session and the system will try automatically reapply it when something is added to the cart. task-2489749 closes odoo/odoo#69053 Signed-off-by: Jérémy Kersten (jke) <jke@openerp.com> Co-authored-by: Romain Derie <rde@odoo.com> Co-authored-by: Jeremy Kersten <jke@odoo.com> Co-authored-by: Tom De Caluwé <tdc@odoo.com>
This commit is contained in:
committed by
Romain Derie
co-authored by
Romain Derie
Jeremy Kersten
parent
280e2f6e04
commit
f57a13f3f6
@@ -52,7 +52,7 @@ class Coupon(models.Model):
|
||||
def _get_default_template(self):
|
||||
return False
|
||||
|
||||
def action_coupon_sent(self):
|
||||
def action_coupon_send(self):
|
||||
""" Open a window to compose an email, with the edi invoice template
|
||||
message loaded by default
|
||||
"""
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<field name="expiration_date"/>
|
||||
<field name="program_id"/>
|
||||
<field name="partner_id"/>
|
||||
<button name="action_coupon_send" string="Send" type="object" icon="fa-paper-plane-o" attrs="{'invisible': [('state', 'not in', ['new', 'sent'])]}"/>
|
||||
<button name="action_coupon_cancel" string="Cancel" type="object" icon="fa-times" attrs="{'invisible': [('state', '!=', 'new')]}"/>
|
||||
<field name="state"/>
|
||||
</tree>
|
||||
</field>
|
||||
@@ -29,7 +31,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Coupons" create="false" edit="false" delete="false">
|
||||
<header>
|
||||
<button name="action_coupon_sent" type="object" string="Send by Email" class="oe_highlight" attrs="{'invisible': [('state', 'not in', ['new', 'sent'])]}"/>
|
||||
<button name="action_coupon_send" type="object" string="Send by Email" class="oe_highlight" attrs="{'invisible': [('state', 'not in', ['new', 'sent'])]}"/>
|
||||
<button name="action_coupon_cancel" type="object" string="Cancel" class="oe_highlight" attrs="{'invisible': [('state', '!=', 'new')]}"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="new,sent,used,expired" context="{'state': state}"/>
|
||||
</header>
|
||||
|
||||
@@ -43,6 +43,7 @@ class Coupon(models.Model):
|
||||
return message
|
||||
|
||||
def _get_default_template(self):
|
||||
if self.order_id:
|
||||
default_template = super()._get_default_template()
|
||||
if not default_template:
|
||||
return self.env.ref('sale_coupon.mail_template_sale_coupon', False)
|
||||
return super()._get_default_template()
|
||||
return default_template
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo import fields, models, _
|
||||
from babel.dates import format_datetime
|
||||
|
||||
class CouponProgram(models.Model):
|
||||
|
||||
@@ -65,7 +65,8 @@ def werkzeugRaiseNotFound(*args, **kwargs):
|
||||
def MockRequest(
|
||||
env, *, routing=True, multilang=True,
|
||||
context=None,
|
||||
cookies=None, country_code=None, website=None, sale_order_id=None
|
||||
cookies=None, country_code=None, website=None, sale_order_id=None,
|
||||
website_sale_current_pl=None,
|
||||
):
|
||||
router = MagicMock()
|
||||
match = router.return_value.bind.return_value.match
|
||||
@@ -102,6 +103,7 @@ def MockRequest(
|
||||
geoip={'country_code': country_code},
|
||||
debug=False,
|
||||
sale_order_id=sale_order_id,
|
||||
website_sale_current_pl=website_sale_current_pl,
|
||||
),
|
||||
website=website
|
||||
)
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
|
||||
@@ -10,14 +10,20 @@ Coupon & promotion programs can be edited in the Catalog menu of the Website app
|
||||
""",
|
||||
'category': 'Website/Website',
|
||||
'version': '1.0',
|
||||
'depends': ['website_sale', 'sale_coupon'],
|
||||
'depends': ['website_sale', 'website_links', 'sale_coupon'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/coupon_share_views.xml',
|
||||
'views/website_sale_templates.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/sale_coupon_coupon_views.xml',
|
||||
'views/sale_coupon_program_views.xml',
|
||||
],
|
||||
'auto_install': True,
|
||||
'auto_install': ['website_sale', 'sale_coupon'],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'website_sale_coupon/static/src/js/coupon_toaster_widget.js',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'website_sale_coupon/static/tests/**/*',
|
||||
],
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import http
|
||||
from odoo import http, _
|
||||
from odoo.addons.website_sale.controllers import main
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
|
||||
from werkzeug.urls import url_encode, url_parse
|
||||
|
||||
|
||||
class WebsiteSale(main.WebsiteSale):
|
||||
|
||||
@@ -28,6 +31,26 @@ class WebsiteSale(main.WebsiteSale):
|
||||
order.recompute_coupon_lines()
|
||||
return super(WebsiteSale, self).cart(**post)
|
||||
|
||||
@http.route(['/coupon/<string:code>'], type='http', auth='public', website=True, sitemap=False)
|
||||
def activate_coupon(self, code, r='/shop', **kw):
|
||||
url_parts = url_parse(r)
|
||||
url_query = url_parts.decode_query()
|
||||
url_query.pop('coupon_error', False) # trust only Odoo error message
|
||||
|
||||
request.session['pending_coupon_code'] = code
|
||||
order = request.website.sale_get_order()
|
||||
if order:
|
||||
result = order._try_pending_coupon()
|
||||
order.recompute_coupon_lines()
|
||||
if isinstance(result, UserError):
|
||||
url_query['coupon_error'] = result
|
||||
else:
|
||||
url_query['notify_coupon'] = code
|
||||
else:
|
||||
url_query['coupon_error'] = _("The coupon will be automatically applied when you add something in your cart.")
|
||||
redirect = url_parts.replace(query=url_encode(url_query))
|
||||
return request.redirect(redirect.to_url())
|
||||
|
||||
# Override
|
||||
# Add in the rendering the free_shipping_line
|
||||
def _get_shop_payment_values(self, order, **kwargs):
|
||||
|
||||
@@ -12,3 +12,8 @@ class SaleCoupon(models.Model):
|
||||
if order and self.program_id.website_id and self.program_id.website_id != order.website_id:
|
||||
return {'error': 'This coupon is not valid on this website.'}
|
||||
return super()._check_coupon_code(order_date, partner_id, **kwargs)
|
||||
|
||||
def action_coupon_share(self):
|
||||
""" Open a window to copy the coupon link """
|
||||
self.ensure_one()
|
||||
return self.env['coupon.share'].create_share_action(coupon=self)
|
||||
|
||||
@@ -34,3 +34,8 @@ class CouponProgram(models.Model):
|
||||
if self.website_id and self.website_id != order.website_id:
|
||||
return {'error': 'This promo code is not valid on this website.'}
|
||||
return super()._check_promo_code(order, coupon_code)
|
||||
|
||||
def action_program_share(self):
|
||||
""" Open a window to copy the program link """
|
||||
self.ensure_one()
|
||||
return self.env['coupon.share'].create_share_action(program=self)
|
||||
|
||||
@@ -2,12 +2,33 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = "sale.order"
|
||||
|
||||
def _try_pending_coupon(self):
|
||||
if not request:
|
||||
return
|
||||
|
||||
pending_coupon_code = request.session.get('pending_coupon_code')
|
||||
if pending_coupon_code:
|
||||
try:
|
||||
self.env['sale.coupon.apply.code'].with_context(active_id=self.id).create({
|
||||
'coupon_code': pending_coupon_code
|
||||
}).process_coupon()
|
||||
request.session.pop('pending_coupon_code')
|
||||
except UserError as e:
|
||||
return e
|
||||
return True
|
||||
|
||||
def recompute_coupon_lines(self):
|
||||
for order in self:
|
||||
order._try_pending_coupon()
|
||||
return super().recompute_coupon_lines()
|
||||
|
||||
def _compute_website_order_line(self):
|
||||
""" This method will merge multiple discount lines generated by a same program
|
||||
into a single one (temporary line with `new()`).
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class Website(models.Model):
|
||||
_inherit = 'website'
|
||||
|
||||
def sale_reset(self):
|
||||
request.session.pop('pending_coupon_code')
|
||||
return super().sale_reset()
|
||||
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_coupon_share,access_coupon_share,model_coupon_share,sales_team.group_sale_manager,1,1,1,0
|
||||
|
@@ -0,0 +1,34 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import publicWidget from 'web.public.widget';
|
||||
import {registry} from "@web/core/registry";
|
||||
|
||||
const CouponToasterWidget = publicWidget.Widget.extend({
|
||||
start() {
|
||||
let options = {};
|
||||
const $content = this.$('.coupon-message-content');
|
||||
const $title = this.$('.coupon-message-title');
|
||||
|
||||
if ($content.length) {
|
||||
Object.assign(options, {message: $content[0].innerHTML});
|
||||
}
|
||||
if ($title.length) {
|
||||
Object.assign(options, {title: $title[0].innerHTML});
|
||||
}
|
||||
|
||||
if (this.$el.hasClass('coupon-info-message')) {
|
||||
this.displayNotification(Object.assign({type: 'success'}, options));
|
||||
} else if (this.$el.hasClass('coupon-error-message')) {
|
||||
this.displayNotification(Object.assign({type: 'danger'}, options));
|
||||
}
|
||||
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
registry.category("public_root_widgets").add("CouponToasterWidget", {
|
||||
Widget: CouponToasterWidget,
|
||||
selector: '.coupon-message',
|
||||
});
|
||||
|
||||
export default CouponToasterWidget;
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import test_apply_pending_coupon
|
||||
from . import test_sale_coupon_multiwebsite
|
||||
from . import test_shop_sale_coupon
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_coupon.tests.test_program_numbers import TestSaleCouponProgramNumbers
|
||||
from odoo.addons.website.tools import MockRequest
|
||||
from odoo.addons.website_sale_coupon.controllers.main import WebsiteSale
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestSaleCouponApplyPending(TestSaleCouponProgramNumbers):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.WebsiteSaleController = WebsiteSale()
|
||||
|
||||
self.website = self.env['website'].browse(1)
|
||||
self.global_program = self.p1
|
||||
self.coupon_program = self.env['coupon.program'].create({
|
||||
'name': 'One Free Product',
|
||||
'program_type': 'coupon_program',
|
||||
'rule_min_quantity': 2.0,
|
||||
'reward_type': 'product',
|
||||
'reward_product_id': self.largeCabinet.id,
|
||||
'active': True,
|
||||
})
|
||||
|
||||
self.coupon = self.env['coupon.coupon'].create({
|
||||
'program_id': self.coupon_program.id,
|
||||
})
|
||||
|
||||
def test_01_activate_coupon_with_existing_program(self):
|
||||
order = self.empty_order
|
||||
|
||||
with MockRequest(self.env, website=self.website, sale_order_id=order.id, website_sale_current_pl=1) as request:
|
||||
self.WebsiteSaleController.cart_update_json(self.largeCabinet.id, set_qty=3)
|
||||
self.WebsiteSaleController.pricelist(self.global_program.promo_code)
|
||||
self.assertEqual(order.amount_total, 864, "The order total should equal 864: 3*320 - 10% discount ")
|
||||
|
||||
self.WebsiteSaleController.activate_coupon(self.coupon.code)
|
||||
promo_code = request.session.get('pending_coupon_code')
|
||||
self.assertFalse(promo_code, "The promo code should be removed from the pending coupon dict")
|
||||
self.assertEqual(order.amount_total, 576, "The order total should equal 576: 3*320 - 320 (free product) - 10%")
|
||||
|
||||
def test_02_pending_coupon_with_existing_program(self):
|
||||
order = self.empty_order
|
||||
|
||||
with MockRequest(self.env, website=self.website, sale_order_id=order.id, website_sale_current_pl=1) as request:
|
||||
self.WebsiteSaleController.cart_update_json(self.largeCabinet.id, set_qty=1)
|
||||
self.WebsiteSaleController.pricelist(self.global_program.promo_code)
|
||||
self.assertEqual(order.amount_total, 288, "The order total should equal 288: 320 - 10%")
|
||||
|
||||
self.WebsiteSaleController.activate_coupon(self.coupon.code)
|
||||
promo_code = request.session.get('pending_coupon_code')
|
||||
self.assertEqual(order.amount_total, 288, "The order total should still equal 288 as the coupon for free product can't be applied since it requires 2 min qty")
|
||||
self.assertEqual(promo_code, self.coupon.code, "The promo code should be set in the pending coupon dict as it couldn't be applied, we save it for later reuse")
|
||||
|
||||
self.WebsiteSaleController.cart_update_json(self.largeCabinet.id, add_qty=2)
|
||||
promo_code = request.session.get('pending_coupon_code')
|
||||
self.assertFalse(promo_code, "The promo code should be removed from the pending coupon dict as it should have been applied")
|
||||
self.assertEqual(order.amount_total, 576, "The order total should equal 576: 3*320 - 320 (free product) - 10%")
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="coupon_share_view_form" model="ir.ui.view">
|
||||
<field name="name">coupon.share.form</field>
|
||||
<field name="model">coupon.share</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Share Coupon" edit="1" create="0">
|
||||
<field name="program_website_id" invisible="1"/>
|
||||
<group attrs="{'invisible': [('website_id', '=', False)]}">
|
||||
<div colspan="2">
|
||||
<p>
|
||||
You can share this promotion with your customers.
|
||||
It will be applied at checkout when the customer uses this link.
|
||||
</p>
|
||||
</div>
|
||||
<field name="share_link" widget="CopyClipboardURL" nolabel="1"/>
|
||||
</group>
|
||||
<hr attrs="{'invisible': ['|', ('website_id', '=', False), ('id', '!=', False)]}"/>
|
||||
<group attrs="{'invisible': [('id', '!=', False)]}">
|
||||
<field name="website_id" groups="website.group_multi_website" widget="selection"
|
||||
attrs="{'invisible': [('program_website_id', '!=', False)]}"/>
|
||||
<field name="redirect"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Done" class="btn-primary" special="cancel" data-hotkey="z"/>
|
||||
<button string="Generate Short Link" class="btn-secondary" type="object" name="action_generate_short_link"
|
||||
attrs="{'invisible': ['|', ('website_id', '=', False), ('id', '!=', False)]}" data-hotkey="g"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="coupon_view_tree" model="ir.ui.view">
|
||||
<field name="name">coupon.coupon.tree</field>
|
||||
<field name="model">coupon.coupon</field>
|
||||
<field name="inherit_id" ref="coupon.coupon_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<button name="action_coupon_send" position="after">
|
||||
<button name="action_coupon_share" string="Share" type="object" icon="fa-share-alt" attrs="{'invisible': [('state', 'not in', ['new', 'sent'])]}"/>
|
||||
</button>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -24,7 +24,9 @@
|
||||
<field name="inherit_id" ref="coupon.coupon_program_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="name" position="after">
|
||||
<field name="promo_code" invisible="1"/>
|
||||
<field name="website_id" groups="website.group_multi_website"/>
|
||||
<button name="action_program_share" string="Share" type="object" icon="fa-share-alt" attrs="{'invisible': [('promo_code', '=', False)]}"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -6,6 +6,22 @@
|
||||
<attribute name="t-if">not line.is_reward_line</attribute>
|
||||
</xpath>
|
||||
</template>
|
||||
<template id="layout" inherit_id="website.layout">
|
||||
<body position="inside">
|
||||
<t t-set="coupon_error" t-value="request.params.get('coupon_error')"/>
|
||||
<t t-set="pending_coupon_code" t-value="request.session.get('pending_coupon_code')"/>
|
||||
<t t-if="coupon_error and pending_coupon_code">
|
||||
<div class="d-none coupon-message coupon-error-message">
|
||||
<span class="coupon-message-title">Could not apply the promo code: <t t-out="pending_coupon_code"/></span>
|
||||
<span class="coupon-message-content" t-out="coupon_error"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set="notify_coupon" t-value="request.params.get('notify_coupon')"/>
|
||||
<div t-if="notify_coupon" class="d-none coupon-message coupon-info-message">
|
||||
<span class="coupon-message-content">The following promo code was applied on your order: <t t-out="notify_coupon"/></span>
|
||||
</div>
|
||||
</body>
|
||||
</template>
|
||||
<template id="sale_coupon_result" inherit_id="website_sale.coupon_form">
|
||||
<xpath expr="//form[@name='coupon_code']" position="after">
|
||||
<t t-if="website_sale_order and website_sale_order.applied_coupon_ids">
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import sale_coupon_share
|
||||
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from werkzeug.urls import url_encode
|
||||
|
||||
from odoo import fields, models, api, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class SaleCouponShare(models.TransientModel):
|
||||
_name = 'coupon.share'
|
||||
_description = 'Create links that apply a coupon and redirect to a specific page'
|
||||
|
||||
def _get_default_website_id(self):
|
||||
# program_website_id = self.program_website_id
|
||||
program_website_id = self.env['coupon.program'].browse(self.env.context.get('default_program_id')).website_id
|
||||
if program_website_id:
|
||||
return program_website_id
|
||||
else:
|
||||
Website = self.env['website']
|
||||
websites = Website.search([])
|
||||
return len(websites) == 1 and websites or Website
|
||||
|
||||
website_id = fields.Many2one('website', required=True, default=_get_default_website_id)
|
||||
coupon_id = fields.Many2one('coupon.coupon', domain="[('program_id', '=', program_id)]")
|
||||
program_id = fields.Many2one('coupon.program', required=True, domain=[
|
||||
'|', ('program_type', '=', 'coupon_program'),
|
||||
('promo_code_usage', '=', 'code_needed'),
|
||||
])
|
||||
program_website_id = fields.Many2one('website', string='Program Website', related='program_id.website_id')
|
||||
|
||||
promo_code = fields.Char(compute='_compute_promo_code')
|
||||
share_link = fields.Char(compute='_compute_share_link')
|
||||
redirect = fields.Char(required=True, default='/shop')
|
||||
|
||||
@api.constrains('coupon_id', 'program_id')
|
||||
def _check_program(self):
|
||||
if self.filtered(lambda record: not record.coupon_id and record.program_id.program_type == 'coupon_program'):
|
||||
raise ValidationError(_("A coupon is needed for coupon programs."))
|
||||
|
||||
@api.constrains('website_id', 'program_id')
|
||||
def _check_website(self):
|
||||
if self.filtered(lambda record: record.program_website_id and record.program_website_id != record.website_id):
|
||||
raise ValidationError(_("The shared website should correspond to the website of the program."))
|
||||
|
||||
@api.depends('coupon_id.code', 'program_id.promo_code')
|
||||
def _compute_promo_code(self):
|
||||
for record in self:
|
||||
record.promo_code = record.coupon_id.code or record.program_id.promo_code
|
||||
|
||||
@api.depends('website_id', 'redirect')
|
||||
@api.depends_context('use_short_link')
|
||||
def _compute_share_link(self):
|
||||
for record in self:
|
||||
target_url = '{base}/coupon/{code}?{query}'.format(
|
||||
base=record.website_id.get_base_url(),
|
||||
code=record.promo_code,
|
||||
query=url_encode({'r': record.redirect}),
|
||||
)
|
||||
|
||||
if record.env.context.get('use_short_link'):
|
||||
tracker = self.env['link.tracker'].search([('url', '=', target_url)], limit=1)
|
||||
if not tracker:
|
||||
tracker = self.env['link.tracker'].create({'url': target_url})
|
||||
record.share_link = tracker.short_url
|
||||
else:
|
||||
record.share_link = target_url
|
||||
|
||||
def action_generate_short_link(self):
|
||||
return {
|
||||
'name': _('Share Coupon'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'coupon.share',
|
||||
'target': 'new',
|
||||
'res_id': self.id,
|
||||
'context': {
|
||||
'use_short_link': True,
|
||||
}
|
||||
}
|
||||
|
||||
@api.model
|
||||
def create_share_action(self, coupon=None, program=None):
|
||||
if bool(program) == bool(coupon):
|
||||
raise UserError(_("Provide either a coupon or a program."))
|
||||
|
||||
return {
|
||||
'name': _('Share Coupon'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'coupon.share',
|
||||
'target': 'new',
|
||||
'flags': {'form': {'action_buttons': True}},
|
||||
'context': {
|
||||
'form_view_initial_mode': 'edit',
|
||||
'default_program_id': program and program.id or coupon.program_id.id,
|
||||
'default_coupon_id': coupon and coupon.id or None,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user