Render templates with uTemplate
This commit is contained in:
2
.coveragerc
Normal file
2
.coveragerc
Normal file
@@ -0,0 +1,2 @@
|
||||
[run]
|
||||
omit=src/utemplate/*
|
||||
17
examples/hello_utemplate.py
Normal file
17
examples/hello_utemplate.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from microdot import Microdot, Response
|
||||
from microdot_utemplate import render_template
|
||||
|
||||
app = Microdot()
|
||||
Response.default_content_type = 'text/html'
|
||||
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
def index(req):
|
||||
name = None
|
||||
if req.method == 'POST':
|
||||
name = req.form.get('name')
|
||||
return render_template('index.html', name=name)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run()
|
||||
17
examples/hello_utemplate_async.py
Normal file
17
examples/hello_utemplate_async.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from microdot_asyncio import Microdot, Response
|
||||
from microdot_utemplate import render_template
|
||||
|
||||
app = Microdot()
|
||||
Response.default_content_type = 'text/html'
|
||||
|
||||
|
||||
@app.route('/', methods=['GET', 'POST'])
|
||||
async def index(req):
|
||||
name = None
|
||||
if req.method == 'POST':
|
||||
name = req.form.get('name')
|
||||
return render_template('index.html', name=name)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run()
|
||||
20
examples/templates/index.html
Normal file
20
examples/templates/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% args name %}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Microdot + uTemplate example</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Microdot + uTemplate example</h1>
|
||||
{% if name %}
|
||||
<p>Hello, <b>{{ name }}</b>!</p>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<p>
|
||||
What is your name?
|
||||
<input type="text" name="name" autofocus />
|
||||
</p>
|
||||
<input type="submit" value="Submit" />
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -374,6 +374,7 @@ class Response():
|
||||
'txt': 'text/plain',
|
||||
}
|
||||
send_file_buffer_size = 1024
|
||||
default_content_type = 'text/plain'
|
||||
|
||||
def __init__(self, body='', status_code=200, headers=None, reason=None):
|
||||
if body is None and status_code == 200:
|
||||
@@ -428,7 +429,7 @@ class Response():
|
||||
'Content-Length' not in self.headers:
|
||||
self.headers['Content-Length'] = str(len(self.body))
|
||||
if 'Content-Type' not in self.headers:
|
||||
self.headers['Content-Type'] = 'text/plain'
|
||||
self.headers['Content-Type'] = self.default_content_type
|
||||
|
||||
def write(self, stream):
|
||||
self.complete()
|
||||
|
||||
@@ -149,6 +149,7 @@ class Response(BaseResponse):
|
||||
|
||||
def body_iter(self):
|
||||
if hasattr(self.body, '__anext__'):
|
||||
# response body is an async generator
|
||||
return self.body
|
||||
|
||||
response = self
|
||||
@@ -156,20 +157,28 @@ class Response(BaseResponse):
|
||||
class iter:
|
||||
def __aiter__(self):
|
||||
if response.body:
|
||||
self.i = 0
|
||||
self.i = 0 # need to determine type of response.body
|
||||
else:
|
||||
self.i = -1
|
||||
self.i = -1 # no response body
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self.i == -1:
|
||||
raise StopAsyncIteration
|
||||
if self.i == 0:
|
||||
if not hasattr(response.body, 'read'):
|
||||
self.i = -1
|
||||
return response.body
|
||||
if hasattr(response.body, 'read'):
|
||||
self.i = 2 # response body is a file-like object
|
||||
elif hasattr(response.body, '__next__'):
|
||||
self.i = 1 # response body is a sync generator
|
||||
return next(response.body)
|
||||
else:
|
||||
self.i = 1
|
||||
self.i = -1 # response body is a plain string
|
||||
return response.body
|
||||
elif self.i == 1:
|
||||
try:
|
||||
return next(response.body)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration
|
||||
buf = response.body.read(response.send_file_buffer_size)
|
||||
if _iscoroutine(buf): # pragma: no cover
|
||||
buf = await buf
|
||||
|
||||
29
src/microdot_utemplate.py
Normal file
29
src/microdot_utemplate.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from utemplate import recompile
|
||||
|
||||
_loader = None
|
||||
|
||||
|
||||
def init_templates(template_dir='templates', loader_class=recompile.Loader):
|
||||
"""Initialize the templating subsystem.
|
||||
|
||||
:param template_dir: the directory where templates are stored. This
|
||||
argument is optional. The default is to load templates
|
||||
from a *templates* subdirectory.
|
||||
:param loader_class: the ``utemplate.Loader`` class to use when loading
|
||||
templates. This argument is optional. The default is
|
||||
the ``recompile.Loader`` class, which automatically
|
||||
recompiles templates when they change.
|
||||
"""
|
||||
global _loader
|
||||
_loader = loader_class(None, template_dir)
|
||||
|
||||
|
||||
def render_template(template, *args, **kwargs):
|
||||
if _loader is None: # pragma: no cover
|
||||
init_templates()
|
||||
render = _loader.load(template)
|
||||
return render(*args, **kwargs)
|
||||
|
||||
|
||||
def render_template_string(template, *args, **kwargs):
|
||||
return ''.join(render_template(template, *args, **kwargs))
|
||||
116
src/utemplate/README.md
Normal file
116
src/utemplate/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
utemplate
|
||||
=========
|
||||
|
||||
*Release: 1.4.1, Source: https://github.com/pfalcon/utemplate*
|
||||
|
||||
`utemplate` is a lightweight and memory-efficient template engine for
|
||||
Python, primarily designed for use with Pycopy, a lightweight Python
|
||||
implementation (https://github.com/pfalcon/pycopy). It is also fully
|
||||
compatible with CPython and other compliant Python implementations.
|
||||
|
||||
`utemplate` syntax is roughly based on Django/Jinja2 syntax (e.g.
|
||||
`{% if %}`, `{{var}}`), but only the most needed features are offered
|
||||
(for example, "filters" (`{{var|filter}}`) are syntactic sugar for
|
||||
function calls, and so far are not planned to be implemented, function
|
||||
calls can be used directly instead: `{{filter(var)}}`).
|
||||
|
||||
`utemplate` compiles templates to Python source code, specifically to
|
||||
a generator function which, being iterated over, produces consecutive
|
||||
parts (substrings) of the rendered template. This allows for minimal
|
||||
memory usage during template substitution (with Pycopy, it starts
|
||||
from mere hundreds of bytes). Generated Python code can be imported as
|
||||
a module directly, or a simple loader class (`utemplate.compiled.Loader`)
|
||||
is provided for convenience.
|
||||
|
||||
There is also a loader class which will compile templates on the fly,
|
||||
if not already compiled - `utemplate.source.Loader`.
|
||||
|
||||
Finally, there's a loader which will automatically recompile a template
|
||||
module if source template is changed - `utemplate.recompile.Loader`.
|
||||
This loader class is the most convenient to use during development, but
|
||||
on the other hand, it performs extra processing not required for a
|
||||
finished/deployed application.
|
||||
|
||||
To test/manage templates, `utemplate_util.py` tool is provided. For
|
||||
example, to quickly try a template (assuming you are already in
|
||||
`examples/` dir):
|
||||
|
||||
pycopy ../utemplate_util.py run squares.tpl
|
||||
|
||||
or
|
||||
|
||||
python3 ../utemplate_util.py run squares.tpl
|
||||
|
||||
Templates can take parameters (that's how dynamic content is generated).
|
||||
Template parameters are passed as arguments to a generator function
|
||||
produced from a template. They also can be passed on the `utemplate_util.py`
|
||||
command line (arguments will be treated as strings in this case, but
|
||||
can be of any types if called from your code):
|
||||
|
||||
pycopy ../utemplate_util.py run test1.tpl foo bar
|
||||
|
||||
Quick Syntax Reference
|
||||
----------------------
|
||||
|
||||
Evaluating Python expression, converting it to a string and outputting to
|
||||
rendered content:
|
||||
|
||||
* `{{<expr>}}`
|
||||
|
||||
Where `expr` is an arbitrary Python expression - from a bare variable name,
|
||||
to function calls, `yield from`/`await` expressions, etc.
|
||||
|
||||
Supported statements:
|
||||
|
||||
* `{% args <var1>, <var2>, ... %}` - specify arguments to a template
|
||||
(optional, should be at the beginning of a template if you want to
|
||||
pass any arguments). All argument types as supported by Python can
|
||||
be used: positional and keyword, with default values, `*args` and
|
||||
`**kwargs` forms, etc.
|
||||
* `{% if <expr> %}`, `{% elif <expr> %}`, `{% else %}`, `{% endif %}` -
|
||||
similar to Python's `if` statement
|
||||
* `{% for <var> in <expr> %}`, `{% endfor %}` - similar to Python's
|
||||
`for` statement
|
||||
* `{% while <expr> %}`, `{% endwhile %}` - similar to Python's `while`
|
||||
statement
|
||||
* `{% set <var> = <expr> %}` - assignment statement
|
||||
* `{% include "name.tpl" %}` - statically include another template
|
||||
* `{% include {{name}} %}` - dynamically include template whose name is
|
||||
stored in variable `name`.
|
||||
|
||||
File Naming Conventions
|
||||
-----------------------
|
||||
|
||||
* The recommended extension for templates is `.tpl`, e.g. `example.tpl`.
|
||||
* When template is compiled, dot (`.`) in its name is replaced
|
||||
with underscore (`_`) and `.py` appended, e.g. `example_tpl.py`. It
|
||||
thus can be imported with `import example_tpl`.
|
||||
* The name passed to `{% include %}` statement should be full name of
|
||||
a template with extension, e.g. `{% include "example.tpl" %}`.
|
||||
* For dynamic form of the `include`, a variable should similarly contain
|
||||
a full name of the template, e.g. `{% set name = "example.tpl" %}` /
|
||||
`{% include {{name}} %}`.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
`examples/squares.tpl` as mentioned in the usage examples above has the
|
||||
following content:
|
||||
|
||||
```
|
||||
{% args n=5 %}
|
||||
{% for i in range(n) %}
|
||||
| {{i}} | {{"%2d" % i ** 2}} |
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
More examples are available in the [examples/](examples/) directory.
|
||||
|
||||
If you want to see a complete example web application which uses `utemplate`,
|
||||
refer to https://github.com/pfalcon/notes-pico .
|
||||
|
||||
License
|
||||
-------
|
||||
|
||||
`utemplate` is written and maintained by Paul Sokolovsky. It's available
|
||||
under the MIT license.
|
||||
14
src/utemplate/compiled.py
Normal file
14
src/utemplate/compiled.py
Normal file
@@ -0,0 +1,14 @@
|
||||
class Loader:
|
||||
|
||||
def __init__(self, pkg, dir):
|
||||
if dir == ".":
|
||||
dir = ""
|
||||
else:
|
||||
dir = dir.replace("/", ".") + "."
|
||||
if pkg and pkg != "__main__":
|
||||
dir = pkg + "." + dir
|
||||
self.p = dir
|
||||
|
||||
def load(self, name):
|
||||
name = name.replace(".", "_")
|
||||
return __import__(self.p + name, None, None, (name,)).render
|
||||
21
src/utemplate/recompile.py
Normal file
21
src/utemplate/recompile.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# (c) 2014-2020 Paul Sokolovsky. MIT license.
|
||||
try:
|
||||
from uos import stat, remove
|
||||
except:
|
||||
from os import stat, remove
|
||||
from . import source
|
||||
|
||||
|
||||
class Loader(source.Loader):
|
||||
|
||||
def load(self, name):
|
||||
o_path = self.pkg_path + self.compiled_path(name)
|
||||
i_path = self.pkg_path + self.dir + "/" + name
|
||||
try:
|
||||
o_stat = stat(o_path)
|
||||
i_stat = stat(i_path)
|
||||
if i_stat[8] > o_stat[8]:
|
||||
# input file is newer, remove output to force recompile
|
||||
remove(o_path)
|
||||
finally:
|
||||
return super().load(name)
|
||||
188
src/utemplate/source.py
Normal file
188
src/utemplate/source.py
Normal file
@@ -0,0 +1,188 @@
|
||||
# (c) 2014-2019 Paul Sokolovsky. MIT license.
|
||||
from . import compiled
|
||||
|
||||
|
||||
class Compiler:
|
||||
|
||||
START_CHAR = "{"
|
||||
STMNT = "%"
|
||||
STMNT_END = "%}"
|
||||
EXPR = "{"
|
||||
EXPR_END = "}}"
|
||||
|
||||
def __init__(self, file_in, file_out, indent=0, seq=0, loader=None):
|
||||
self.file_in = file_in
|
||||
self.file_out = file_out
|
||||
self.loader = loader
|
||||
self.seq = seq
|
||||
self._indent = indent
|
||||
self.stack = []
|
||||
self.in_literal = False
|
||||
self.flushed_header = False
|
||||
self.args = "*a, **d"
|
||||
|
||||
def indent(self, adjust=0):
|
||||
if not self.flushed_header:
|
||||
self.flushed_header = True
|
||||
self.indent()
|
||||
self.file_out.write("def render%s(%s):\n" % (str(self.seq) if self.seq else "", self.args))
|
||||
self.stack.append("def")
|
||||
self.file_out.write(" " * (len(self.stack) + self._indent + adjust))
|
||||
|
||||
def literal(self, s):
|
||||
if not s:
|
||||
return
|
||||
if not self.in_literal:
|
||||
self.indent()
|
||||
self.file_out.write('yield """')
|
||||
self.in_literal = True
|
||||
self.file_out.write(s.replace('"', '\\"'))
|
||||
|
||||
def close_literal(self):
|
||||
if self.in_literal:
|
||||
self.file_out.write('"""\n')
|
||||
self.in_literal = False
|
||||
|
||||
def render_expr(self, e):
|
||||
self.indent()
|
||||
self.file_out.write('yield str(' + e + ')\n')
|
||||
|
||||
def parse_statement(self, stmt):
|
||||
tokens = stmt.split(None, 1)
|
||||
if tokens[0] == "args":
|
||||
if len(tokens) > 1:
|
||||
self.args = tokens[1]
|
||||
else:
|
||||
self.args = ""
|
||||
elif tokens[0] == "set":
|
||||
self.indent()
|
||||
self.file_out.write(stmt[3:].strip() + "\n")
|
||||
elif tokens[0] == "include":
|
||||
if not self.flushed_header:
|
||||
# If there was no other output, we still need a header now
|
||||
self.indent()
|
||||
tokens = tokens[1].split(None, 1)
|
||||
args = ""
|
||||
if len(tokens) > 1:
|
||||
args = tokens[1]
|
||||
if tokens[0][0] == "{":
|
||||
self.indent()
|
||||
# "1" as fromlist param is uPy hack
|
||||
self.file_out.write('_ = __import__(%s.replace(".", "_"), None, None, 1)\n' % tokens[0][2:-2])
|
||||
self.indent()
|
||||
self.file_out.write("yield from _.render(%s)\n" % args)
|
||||
return
|
||||
|
||||
with self.loader.input_open(tokens[0][1:-1]) as inc:
|
||||
self.seq += 1
|
||||
c = Compiler(inc, self.file_out, len(self.stack) + self._indent, self.seq)
|
||||
inc_id = self.seq
|
||||
self.seq = c.compile()
|
||||
self.indent()
|
||||
self.file_out.write("yield from render%d(%s)\n" % (inc_id, args))
|
||||
elif len(tokens) > 1:
|
||||
if tokens[0] == "elif":
|
||||
assert self.stack[-1] == "if"
|
||||
self.indent(-1)
|
||||
self.file_out.write(stmt + ":\n")
|
||||
else:
|
||||
self.indent()
|
||||
self.file_out.write(stmt + ":\n")
|
||||
self.stack.append(tokens[0])
|
||||
else:
|
||||
if stmt.startswith("end"):
|
||||
assert self.stack[-1] == stmt[3:]
|
||||
self.stack.pop(-1)
|
||||
elif stmt == "else":
|
||||
assert self.stack[-1] == "if"
|
||||
self.indent(-1)
|
||||
self.file_out.write("else:\n")
|
||||
else:
|
||||
assert False
|
||||
|
||||
def parse_line(self, l):
|
||||
while l:
|
||||
start = l.find(self.START_CHAR)
|
||||
if start == -1:
|
||||
self.literal(l)
|
||||
return
|
||||
self.literal(l[:start])
|
||||
self.close_literal()
|
||||
sel = l[start + 1]
|
||||
#print("*%s=%s=" % (sel, EXPR))
|
||||
if sel == self.STMNT:
|
||||
end = l.find(self.STMNT_END)
|
||||
assert end > 0
|
||||
stmt = l[start + len(self.START_CHAR + self.STMNT):end].strip()
|
||||
self.parse_statement(stmt)
|
||||
end += len(self.STMNT_END)
|
||||
l = l[end:]
|
||||
if not self.in_literal and l == "\n":
|
||||
break
|
||||
elif sel == self.EXPR:
|
||||
# print("EXPR")
|
||||
end = l.find(self.EXPR_END)
|
||||
assert end > 0
|
||||
expr = l[start + len(self.START_CHAR + self.EXPR):end].strip()
|
||||
self.render_expr(expr)
|
||||
end += len(self.EXPR_END)
|
||||
l = l[end:]
|
||||
else:
|
||||
self.literal(l[start])
|
||||
l = l[start + 1:]
|
||||
|
||||
def header(self):
|
||||
self.file_out.write("# Autogenerated file\n")
|
||||
|
||||
def compile(self):
|
||||
self.header()
|
||||
for l in self.file_in:
|
||||
self.parse_line(l)
|
||||
self.close_literal()
|
||||
return self.seq
|
||||
|
||||
|
||||
class Loader(compiled.Loader):
|
||||
|
||||
def __init__(self, pkg, dir):
|
||||
super().__init__(pkg, dir)
|
||||
self.dir = dir
|
||||
if pkg == "__main__":
|
||||
# if pkg isn't really a package, don't bother to use it
|
||||
# it means we're running from "filesystem directory", not
|
||||
# from a package.
|
||||
pkg = None
|
||||
|
||||
self.pkg_path = ""
|
||||
if pkg:
|
||||
p = __import__(pkg)
|
||||
if isinstance(p.__path__, str):
|
||||
# uPy
|
||||
self.pkg_path = p.__path__
|
||||
else:
|
||||
# CPy
|
||||
self.pkg_path = p.__path__[0]
|
||||
self.pkg_path += "/"
|
||||
|
||||
def input_open(self, template):
|
||||
path = self.pkg_path + self.dir + "/" + template
|
||||
return open(path)
|
||||
|
||||
def compiled_path(self, template):
|
||||
return self.dir + "/" + template.replace(".", "_") + ".py"
|
||||
|
||||
def load(self, name):
|
||||
try:
|
||||
return super().load(name)
|
||||
except (OSError, ImportError):
|
||||
pass
|
||||
|
||||
compiled_path = self.pkg_path + self.compiled_path(name)
|
||||
|
||||
f_in = self.input_open(name)
|
||||
f_out = open(compiled_path, "w")
|
||||
c = Compiler(f_in, f_out, loader=self)
|
||||
c.compile()
|
||||
f_in.close()
|
||||
f_out.close()
|
||||
return super().load(name)
|
||||
@@ -7,3 +7,5 @@ from tests.microdot.test_microdot import TestMicrodot
|
||||
from tests.microdot_asyncio.test_request_asyncio import TestRequestAsync
|
||||
from tests.microdot_asyncio.test_response_asyncio import TestResponseAsync
|
||||
from tests.microdot_asyncio.test_microdot_asyncio import TestMicrodotAsync
|
||||
|
||||
from tests.microdot_utemplate.test_utemplate import TestUTemplate
|
||||
|
||||
2
tests/microdot_utemplate/templates/hello.txt
Normal file
2
tests/microdot_utemplate/templates/hello.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
{% args name %}
|
||||
Hello, {{ name }}!
|
||||
6
tests/microdot_utemplate/templates/hello_txt.py
Normal file
6
tests/microdot_utemplate/templates/hello_txt.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# Autogenerated file
|
||||
def render(name):
|
||||
yield """Hello, """
|
||||
yield str(name)
|
||||
yield """!
|
||||
"""
|
||||
60
tests/microdot_utemplate/test_utemplate.py
Normal file
60
tests/microdot_utemplate/test_utemplate.py
Normal file
@@ -0,0 +1,60 @@
|
||||
try:
|
||||
import uasyncio as asyncio
|
||||
except ImportError:
|
||||
import asyncio
|
||||
|
||||
import unittest
|
||||
from microdot import Microdot, Request
|
||||
from microdot_asyncio import Microdot as MicrodotAsync, Request as RequestAsync
|
||||
from microdot_utemplate import render_template, render_template_string, \
|
||||
init_templates
|
||||
from tests.mock_socket import get_request_fd, get_async_request_fd
|
||||
|
||||
init_templates('tests/microdot_utemplate/templates')
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
class TestUTemplate(unittest.TestCase):
|
||||
def test_render_template(self):
|
||||
s = list(render_template('hello.txt', name='foo'))
|
||||
self.assertEqual(s, ['Hello, ', 'foo', '!\n'])
|
||||
|
||||
def test_render_template_string(self):
|
||||
s = render_template_string('hello.txt', name='foo')
|
||||
self.assertEqual(s.strip(), 'Hello, foo!')
|
||||
|
||||
def test_render_template_in_app(self):
|
||||
app = Microdot()
|
||||
|
||||
@app.route('/')
|
||||
def index(req):
|
||||
return render_template('hello.txt', name='foo')
|
||||
|
||||
req = Request.create(app, get_request_fd('GET', '/'), 'addr')
|
||||
res = app.dispatch_request(req)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(list(res.body_iter()), ['Hello, ', 'foo', '!\n'])
|
||||
|
||||
def test_render_template_in_app_async(self):
|
||||
app = MicrodotAsync()
|
||||
|
||||
@app.route('/')
|
||||
async def index(req):
|
||||
return render_template('hello.txt', name='foo')
|
||||
|
||||
req = _run(RequestAsync.create(
|
||||
app, get_async_request_fd('GET', '/'), 'addr'))
|
||||
res = _run(app.dispatch_request(req))
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
async def get_result():
|
||||
result = []
|
||||
async for chunk in res.body_iter():
|
||||
result.append(chunk)
|
||||
return result
|
||||
|
||||
result = _run(get_result())
|
||||
self.assertEqual(result, ['Hello, ', 'foo', '!\n'])
|
||||
4
tox.ini
4
tox.ini
@@ -15,7 +15,7 @@ python =
|
||||
[testenv]
|
||||
commands=
|
||||
pip install -e .
|
||||
pytest -p no:logging --cov=src --cov-branch --cov-report=term-missing
|
||||
pytest -p no:logging --cov=src --cov-config=.coveragerc --cov-branch --cov-report=term-missing
|
||||
deps=
|
||||
pytest
|
||||
pytest-cov
|
||||
@@ -24,7 +24,7 @@ deps=
|
||||
deps=
|
||||
flake8
|
||||
commands=
|
||||
flake8 --ignore=W503 --exclude tests/libs src tests examples
|
||||
flake8 --ignore=W503 --exclude src/utemplate,tests/libs src tests examples
|
||||
|
||||
[testenv:upy]
|
||||
whitelist_externals=sh
|
||||
|
||||
Reference in New Issue
Block a user