refactor #1
@ -8,9 +8,9 @@ site_defaults:
|
||||
pages: ./templates/pages
|
||||
sites:
|
||||
main:
|
||||
build_cache: ./build
|
||||
build_cache: ./site
|
||||
assets:
|
||||
- ./assets
|
||||
- /assets
|
||||
articles:
|
||||
- ./pages/*.md
|
||||
resume:
|
||||
@ -24,7 +24,7 @@ sites:
|
||||
build_cache: ./build/dogma-jimfinium
|
||||
web_root: ./dist/dogma-jimfinium
|
||||
assets:
|
||||
- '{build_cache}/assets'
|
||||
- assets
|
||||
articles:
|
||||
- '{build_cache}/*.md'
|
||||
- '*.md'
|
||||
|
||||
53
main.py
53
main.py
@ -1,4 +1,6 @@
|
||||
import os
|
||||
import glob
|
||||
import shutil
|
||||
import subprocess
|
||||
import markdown
|
||||
import yaml
|
||||
@ -30,7 +32,7 @@ class ArticleMetadata(pydantic.BaseModel):
|
||||
title: str
|
||||
author: Optional[str] = config.get('author')
|
||||
date: date
|
||||
lastmod: Optional[date]
|
||||
lastmod: Optional[date] = None
|
||||
published: bool
|
||||
tags: list
|
||||
thumbnail: Optional[str] = None
|
||||
@ -73,20 +75,13 @@ def format_html_template(template: str, **kwargs) -> str:
|
||||
return html
|
||||
|
||||
|
||||
REPOS = [
|
||||
'ssh://gitea/jim/resume.git',
|
||||
'ssh://gitea/jim/dogma-jimfinium.git'
|
||||
]
|
||||
run = lambda cmd: subprocess.run(cmd.split(' '), stdout = subprocess.PIPE, stderr = subprocess.PIPE)
|
||||
def update_git_repos(repos: list) -> None:
|
||||
'''Pulls updates to repos in the build directory, or clones them if they don't exist.'''
|
||||
for repo in repos:
|
||||
local_path = 'build/'+repo.split('/')[-1].strip('.git')
|
||||
print(local_path)
|
||||
if os.path.exists(f'{local_path}/.git'):
|
||||
run(f'git -C {local_path} pull origin')
|
||||
def pull_git_repo(repo: str, build_cache: str) -> None:
|
||||
'''Pulls/clones a repo into the build cache directory.'''
|
||||
if os.path.exists(f'{build_cache}/.git'):
|
||||
run(f'git -C {build_cache} pull origin')
|
||||
else:
|
||||
run(f'git clone {repo} {local_path}')
|
||||
run(f'git clone {repo} {build_cache}')
|
||||
|
||||
|
||||
def load_partials() -> dict:
|
||||
@ -147,7 +142,7 @@ def build_blog_archive(
|
||||
|
||||
# Add each article as a list item to an unordered list.
|
||||
archive_html_content = '<ul>'
|
||||
for article, (metadata, contents) in sorted(index.items(), key = lambda item: item[1][0]['date'])[::-1]:
|
||||
for article, (metadata, contents) in sorted(index.items(), key = lambda item: item[1][0].date)[::-1]:
|
||||
|
||||
# Generate HTML for the article (including metadata tags).
|
||||
archive_html_content += format_html_template(
|
||||
@ -168,5 +163,35 @@ def build_blog_archive(
|
||||
|
||||
return archive_html_page
|
||||
|
||||
def copy_assets(site: SiteConfig):
|
||||
'''Copies the list of site assets from the build cache to the web root.'''
|
||||
|
||||
# Expand any globbed expressions.
|
||||
expanded_asset_list = []
|
||||
for a in site.assets:
|
||||
expanded_asset_list.extend(
|
||||
# Assets are defined relative to the build cache; construct the full path.
|
||||
glob.glob(f'{site.build_cache}/{a.lstrip("/")}')
|
||||
)
|
||||
|
||||
for asset in expanded_asset_list:
|
||||
|
||||
# Construct the destination path analogous to the source path
|
||||
# but in the web root instead of the build cache.
|
||||
destination = f'{site.web_root}/{a.lstrip("/")}'
|
||||
|
||||
# Delete existing files.
|
||||
shutil.rmtree(destination, ignore_errors=True)
|
||||
|
||||
# Copy the asset.
|
||||
if os.path.isdir(asset):
|
||||
shutil.copytree(asset, destination)
|
||||
elif os.path.isfile(asset):
|
||||
shutil.copyfile(asset, destination)
|
||||
else:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
if __name__ == '__main__':
|
||||
pass
|
||||
122
testbench.ipynb
122
testbench.ipynb
@ -27,7 +27,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 11,
|
||||
"id": "207d2510",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@ -39,6 +39,7 @@
|
||||
"import subprocess\n",
|
||||
"import rfeed\n",
|
||||
"import pydantic\n",
|
||||
"import glob\n",
|
||||
"from typing import Optional, Union, Literal, BinaryIO, Any\n",
|
||||
"\n",
|
||||
"\n",
|
||||
@ -49,7 +50,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 2,
|
||||
"id": "4b17a3ed",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@ -72,24 +73,10 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 3,
|
||||
"id": "d2361c42",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"ename": "ValidationError",
|
||||
"evalue": "2 validation errors for ArticleMetadata\ndate\n Input should be a valid string [type=string_type, input_value=datetime.date(2026, 1, 30), input_type=date]\n For further information visit https://errors.pydantic.dev/2.12/v/string_type\nlastmod\n Field required [type=missing, input_value={'title': 'Welcome', 'dat...shed': True, 'tags': []}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.12/v/missing",
|
||||
"output_type": "error",
|
||||
"traceback": [
|
||||
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
||||
"\u001b[31mValidationError\u001b[39m Traceback (most recent call last)",
|
||||
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[3]\u001b[39m\u001b[32m, line 1\u001b[39m\n\u001b[32m----> \u001b[39m\u001b[32m1\u001b[39m metadata, content = \u001b[43mload_markdown\u001b[49m\u001b[43m(\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mpages/home.md\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[32m 2\u001b[39m \u001b[38;5;66;03m# content = content.replace('src=\"assets', 'src=\"../tmp/dogma-jimfinium/assets')\u001b[39;00m\n",
|
||||
"\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/main.py:59\u001b[39m, in \u001b[36mload_markdown\u001b[39m\u001b[34m(md)\u001b[39m\n\u001b[32m 56\u001b[39m \u001b[38;5;66;03m# Convert the contents to a HTML string.\u001b[39;00m\n\u001b[32m 57\u001b[39m content = markdown.markdown(raw_article)\n\u001b[32m---> \u001b[39m\u001b[32m59\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mArticleMetadata\u001b[49m\u001b[43m(\u001b[49m\u001b[43m*\u001b[49m\u001b[43m*\u001b[49m\u001b[43mmetadata\u001b[49m\u001b[43m)\u001b[49m, content\n",
|
||||
"\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/.venv/lib/python3.12/site-packages/pydantic/main.py:250\u001b[39m, in \u001b[36mBaseModel.__init__\u001b[39m\u001b[34m(self, **data)\u001b[39m\n\u001b[32m 248\u001b[39m \u001b[38;5;66;03m# `__tracebackhide__` tells pytest and some other tools to omit this function from tracebacks\u001b[39;00m\n\u001b[32m 249\u001b[39m __tracebackhide__ = \u001b[38;5;28;01mTrue\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m250\u001b[39m validated_self = \u001b[38;5;28;43mself\u001b[39;49m\u001b[43m.\u001b[49m\u001b[43m__pydantic_validator__\u001b[49m\u001b[43m.\u001b[49m\u001b[43mvalidate_python\u001b[49m\u001b[43m(\u001b[49m\u001b[43mdata\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mself_instance\u001b[49m\u001b[43m=\u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[32m 251\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m validated_self:\n\u001b[32m 252\u001b[39m warnings.warn(\n\u001b[32m 253\u001b[39m \u001b[33m'\u001b[39m\u001b[33mA custom validator is returning a value other than `self`.\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m'\u001b[39m\n\u001b[32m 254\u001b[39m \u001b[33m\"\u001b[39m\u001b[33mReturning anything other than `self` from a top level model validator isn\u001b[39m\u001b[33m'\u001b[39m\u001b[33mt supported when validating via `__init__`.\u001b[39m\u001b[38;5;130;01m\\n\u001b[39;00m\u001b[33m\"\u001b[39m\n\u001b[32m 255\u001b[39m \u001b[33m'\u001b[39m\u001b[33mSee the `model_validator` docs (https://docs.pydantic.dev/latest/concepts/validators/#model-validators) for more details.\u001b[39m\u001b[33m'\u001b[39m,\n\u001b[32m 256\u001b[39m stacklevel=\u001b[32m2\u001b[39m,\n\u001b[32m 257\u001b[39m )\n",
|
||||
"\u001b[31mValidationError\u001b[39m: 2 validation errors for ArticleMetadata\ndate\n Input should be a valid string [type=string_type, input_value=datetime.date(2026, 1, 30), input_type=date]\n For further information visit https://errors.pydantic.dev/2.12/v/string_type\nlastmod\n Field required [type=missing, input_value={'title': 'Welcome', 'dat...shed': True, 'tags': []}, input_type=dict]\n For further information visit https://errors.pydantic.dev/2.12/v/missing"
|
||||
]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"metadata, content = load_markdown('pages/home.md')\n",
|
||||
"# content = content.replace('src=\"assets', 'src=\"../tmp/dogma-jimfinium/assets')"
|
||||
@ -97,20 +84,12 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "0bb15524",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 5,
|
||||
"id": "ed7b3b2f",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"html = format_html_template('templates/pages/default.html', content = content, **{'metadata.'+k:v for k,v in metadata.items()}, **PARTIALS)\n",
|
||||
"html = format_html_template('templates/pages/default.html', content = content, metadata = metadata, **PARTIALS)\n",
|
||||
"with open('dist/home.html', 'w') as f:\n",
|
||||
" f.write(html)"
|
||||
]
|
||||
@ -151,10 +130,76 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 12,
|
||||
"id": "dafd924b",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"['build/dogma-jimfinium/superlock.md',\n",
|
||||
" 'build/dogma-jimfinium/sustainable-living.md',\n",
|
||||
" 'build/dogma-jimfinium/stocking-up.md',\n",
|
||||
" 'build/dogma-jimfinium/set-up-the-toys.md',\n",
|
||||
" 'build/dogma-jimfinium/babies-love-trash.md',\n",
|
||||
" 'build/dogma-jimfinium/do-what-you-love.md',\n",
|
||||
" 'build/dogma-jimfinium/self-care-is-not-selfish.md',\n",
|
||||
" 'build/dogma-jimfinium/temptation.md',\n",
|
||||
" 'build/dogma-jimfinium/blowouts.md',\n",
|
||||
" 'build/dogma-jimfinium/vitamins.md',\n",
|
||||
" 'build/dogma-jimfinium/gear-for-new-parents.md']"
|
||||
]
|
||||
},
|
||||
"execution_count": 12,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"glob.glob('build/dogma-jimfinium/*.md')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 13,
|
||||
"id": "cced61c4",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"'gear-for-new-parents.md'"
|
||||
]
|
||||
},
|
||||
"execution_count": 13,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"os.path.basename( 'build/dogma-jimfinium/gear-for-new-parents.md')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 14,
|
||||
"id": "944a5efd",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"ename": "TypeError",
|
||||
"evalue": "'ArticleMetadata' object is not subscriptable",
|
||||
"output_type": "error",
|
||||
"traceback": [
|
||||
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
||||
"\u001b[31mTypeError\u001b[39m Traceback (most recent call last)",
|
||||
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[14]\u001b[39m\u001b[32m, line 36\u001b[39m\n\u001b[32m 32\u001b[39m \u001b[38;5;28;01mwith\u001b[39;00m \u001b[38;5;28mopen\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mdist/dogma-jimfinium/\u001b[39m\u001b[38;5;132;01m{\u001b[39;00marticle_filestem\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m.html\u001b[39m\u001b[33m'\u001b[39m, \u001b[33m'\u001b[39m\u001b[33mw\u001b[39m\u001b[33m'\u001b[39m) \u001b[38;5;28;01mas\u001b[39;00m f:\n\u001b[32m 33\u001b[39m f.write(html)\n\u001b[32m---> \u001b[39m\u001b[32m36\u001b[39m index_html = \u001b[43mbuild_blog_archive\u001b[49m\u001b[43m(\u001b[49m\u001b[43mindex\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43m*\u001b[49m\u001b[43m*\u001b[49m\u001b[43mPARTIALS\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 37\u001b[39m \u001b[38;5;66;03m# Write the HTML file to /dist/dogma-jimfinium.\u001b[39;00m\n\u001b[32m 38\u001b[39m \u001b[38;5;28;01mwith\u001b[39;00m \u001b[38;5;28mopen\u001b[39m(\u001b[33mf\u001b[39m\u001b[33m'\u001b[39m\u001b[33mdist/dogma-jimfinium/index.html\u001b[39m\u001b[33m'\u001b[39m, \u001b[33m'\u001b[39m\u001b[33mw\u001b[39m\u001b[33m'\u001b[39m) \u001b[38;5;28;01mas\u001b[39;00m f:\n",
|
||||
"\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/main.py:150\u001b[39m, in \u001b[36mbuild_blog_archive\u001b[39m\u001b[34m(index, page_template, li_template, **kwargs)\u001b[39m\n\u001b[32m 148\u001b[39m \u001b[38;5;66;03m# Add each article as a list item to an unordered list.\u001b[39;00m\n\u001b[32m 149\u001b[39m archive_html_content = \u001b[33m'\u001b[39m\u001b[33m<ul>\u001b[39m\u001b[33m'\u001b[39m\n\u001b[32m--> \u001b[39m\u001b[32m150\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m article, (metadata, contents) \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28;43msorted\u001b[39;49m\u001b[43m(\u001b[49m\u001b[43mindex\u001b[49m\u001b[43m.\u001b[49m\u001b[43mitems\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mkey\u001b[49m\u001b[43m \u001b[49m\u001b[43m=\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43;01mlambda\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mitem\u001b[49m\u001b[43m:\u001b[49m\u001b[43m \u001b[49m\u001b[43mitem\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m1\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m0\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mdate\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m)\u001b[49m[::-\u001b[32m1\u001b[39m]:\n\u001b[32m 151\u001b[39m \n\u001b[32m 152\u001b[39m \u001b[38;5;66;03m# Generate HTML for the article (including metadata tags).\u001b[39;00m\n\u001b[32m 153\u001b[39m archive_html_content += format_html_template(\n\u001b[32m 154\u001b[39m li_template,\n\u001b[32m 155\u001b[39m article_filestem = article,\n\u001b[32m (...)\u001b[39m\u001b[32m 158\u001b[39m \n\u001b[32m 159\u001b[39m )\n\u001b[32m 160\u001b[39m archive_html_content +=\u001b[33m'\u001b[39m\u001b[33m</ul>\u001b[39m\u001b[33m'\u001b[39m\n",
|
||||
"\u001b[36mFile \u001b[39m\u001b[32m~/projects/shepich.com/main.py:150\u001b[39m, in \u001b[36mbuild_blog_archive.<locals>.<lambda>\u001b[39m\u001b[34m(item)\u001b[39m\n\u001b[32m 148\u001b[39m \u001b[38;5;66;03m# Add each article as a list item to an unordered list.\u001b[39;00m\n\u001b[32m 149\u001b[39m archive_html_content = \u001b[33m'\u001b[39m\u001b[33m<ul>\u001b[39m\u001b[33m'\u001b[39m\n\u001b[32m--> \u001b[39m\u001b[32m150\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m article, (metadata, contents) \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28msorted\u001b[39m(index.items(), key = \u001b[38;5;28;01mlambda\u001b[39;00m item: \u001b[43mitem\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m1\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[32;43m0\u001b[39;49m\u001b[43m]\u001b[49m\u001b[43m[\u001b[49m\u001b[33;43m'\u001b[39;49m\u001b[33;43mdate\u001b[39;49m\u001b[33;43m'\u001b[39;49m\u001b[43m]\u001b[49m)[::-\u001b[32m1\u001b[39m]:\n\u001b[32m 151\u001b[39m \n\u001b[32m 152\u001b[39m \u001b[38;5;66;03m# Generate HTML for the article (including metadata tags).\u001b[39;00m\n\u001b[32m 153\u001b[39m archive_html_content += format_html_template(\n\u001b[32m 154\u001b[39m li_template,\n\u001b[32m 155\u001b[39m article_filestem = article,\n\u001b[32m (...)\u001b[39m\u001b[32m 158\u001b[39m \n\u001b[32m 159\u001b[39m )\n\u001b[32m 160\u001b[39m archive_html_content +=\u001b[33m'\u001b[39m\u001b[33m</ul>\u001b[39m\u001b[33m'\u001b[39m\n",
|
||||
"\u001b[31mTypeError\u001b[39m: 'ArticleMetadata' object is not subscriptable"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"shutil.rmtree('dist/dogma-jimfinium', ignore_errors=True)\n",
|
||||
"os.makedirs('dist/dogma-jimfinium', exist_ok=True)\n",
|
||||
@ -162,14 +207,17 @@
|
||||
"\n",
|
||||
"index = {}\n",
|
||||
"\n",
|
||||
"for article in os.listdir('build/dogma-jimfinium'):\n",
|
||||
" metadata, content = load_markdown(f'build/dogma-jimfinium/{article}')\n",
|
||||
"for article in glob.glob('build/dogma-jimfinium/*.md'):\n",
|
||||
" metadata, content = load_markdown(article)\n",
|
||||
"\n",
|
||||
" if metadata is None:\n",
|
||||
" print(article)\n",
|
||||
"\n",
|
||||
" # Skip unpublished articles.\n",
|
||||
" if not metadata.get('published'):\n",
|
||||
" if not metadata.published:\n",
|
||||
" continue\n",
|
||||
"\n",
|
||||
" article_filestem = os.path.splitext(article)[0]\n",
|
||||
" article_filestem = os.path.splitext(os.path.basename(article))[0]\n",
|
||||
"\n",
|
||||
" # Add the article to the index.\n",
|
||||
" index[article_filestem] = (metadata, content)\n",
|
||||
@ -178,8 +226,8 @@
|
||||
" article_html = format_html_template(\n",
|
||||
" 'templates/components/blog_article.html',\n",
|
||||
" content = content,\n",
|
||||
" blog_tags = ' '.join(format_blog_tags(metadata['tags'])),\n",
|
||||
" **{'metadata.'+k:v for k,v in metadata.items()}\n",
|
||||
" blog_tags = ' '.join(format_blog_tags(metadata.tags)),\n",
|
||||
" metadata = metadata\n",
|
||||
" )\n",
|
||||
" html = format_html_template('templates/pages/default.html', content = article_html, **PARTIALS)\n",
|
||||
" \n",
|
||||
@ -188,7 +236,7 @@
|
||||
" f.write(html)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"index_html = build_blog_archive(index, metadata.title = 'Dogma Jimfinium | Index', **PARTIALS)\n",
|
||||
"index_html = build_blog_archive(index, **PARTIALS)\n",
|
||||
"# Write the HTML file to /dist/dogma-jimfinium.\n",
|
||||
"with open(f'dist/dogma-jimfinium/index.html', 'w') as f:\n",
|
||||
" f.write(index_html)"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user