Adding admin and report endpoints
This commit is contained in:
parent
af299a53c7
commit
6c128d0567
18 changed files with 335 additions and 6 deletions
|
|
@ -2,12 +2,16 @@
|
||||||
|
|
||||||
## 0.8-dev
|
## 0.8-dev
|
||||||
|
|
||||||
* Schema changes: moved `full_name` field from table `userprofile` to table `user` for search improvement reasons.
|
* Added the admin dashboard, accessible from `/admin/` via basic auth. Only users with admin right can access it. Added endpoints `admin.reports` and `admin.reports_detail`.
|
||||||
|
* Safety is our top priority: added the ability to report someone other's post for everything violating the site's Terms of Service. The current reasons for reporting are: spam, impersonation, pornography, violence, harassment or bullying, hate speech or symbols, self injury, sale or promotion of firearms or drugs, and underage use.
|
||||||
|
* Schema changes: moved `full_name` field from table `userprofile` to table `user` for search improvement reasons. Added `Report` model.
|
||||||
|
* Now `profile_search` API endpoint searches by full name too.
|
||||||
* Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (what I've done to 0.7.1 too).
|
* Adding `messages_count`, `followers_count` and `following_count` to `profile_info` API endpoint (what I've done to 0.7.1 too).
|
||||||
* Adding `create2` API endpoint that accepts media, due to an issue with the `create` endpoint that would make it incompatible.
|
* Adding `create2` API endpoint that accepts media, due to an issue with the `create` endpoint that would make it incompatible.
|
||||||
* Adding media URLs to messages in API.
|
* Adding media URLs to messages in API.
|
||||||
* Added `relationships_follow`, `relationships_unfollow`, `username_availability` and `edit_profile` endpoints to API.
|
* Added `relationships_follow`, `relationships_unfollow`, `username_availability` and `edit_profile` endpoints to API.
|
||||||
* Added `url` utility to model `Upload`.
|
* Added `url` utility to model `Upload`.
|
||||||
|
* Changed default `robots.txt`, adding report and admin-related lines.
|
||||||
|
|
||||||
## 0.7.1-dev
|
## 0.7.1-dev
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ To run the app, do "flask run" in the package's parent directory.
|
||||||
|
|
||||||
Based on Tweepee example of [peewee](https://github.com/coleifer/peewee/).
|
Based on Tweepee example of [peewee](https://github.com/coleifer/peewee/).
|
||||||
|
|
||||||
|
This is the server. For the client, see [coriplusapp](https://github.com/sakuragasaki46/coriplusapp/).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* Create text statuses, optionally with image
|
* Create text statuses, optionally with image
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ This module also contains very basic web hooks, such as robots.txt.
|
||||||
|
|
||||||
For the website hooks, see `app.website`.
|
For the website hooks, see `app.website`.
|
||||||
For the AJAX hook, see `app.ajax`.
|
For the AJAX hook, see `app.ajax`.
|
||||||
|
For public API, see `app.api`.
|
||||||
|
For report pages, see `app.reports`.
|
||||||
|
For site administration, see `app.admin`.
|
||||||
For template filters, see `app.filters`.
|
For template filters, see `app.filters`.
|
||||||
For the database models, see `app.models`.
|
For the database models, see `app.models`.
|
||||||
For other, see `app.utils`.
|
For other, see `app.utils`.
|
||||||
|
|
@ -118,4 +121,8 @@ app.register_blueprint(bp)
|
||||||
from .api import bp
|
from .api import bp
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
|
from .reports import bp
|
||||||
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
|
from .admin import bp
|
||||||
|
app.register_blueprint(bp)
|
||||||
|
|
|
||||||
66
app/admin.py
Normal file
66
app/admin.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
'''
|
||||||
|
Management of reports and the entire site.
|
||||||
|
|
||||||
|
New in 0.8.
|
||||||
|
'''
|
||||||
|
|
||||||
|
from flask import Blueprint, redirect, render_template, request, url_for
|
||||||
|
from .models import User, Message, Report, report_reasons, REPORT_STATUS_ACCEPTED, \
|
||||||
|
REPORT_MEDIA_USER, REPORT_MEDIA_MESSAGE
|
||||||
|
from .utils import pwdhash, object_list
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||||
|
|
||||||
|
def check_auth(username, password):
|
||||||
|
try:
|
||||||
|
return User.get((User.username == username) & (User.password == pwdhash(password))
|
||||||
|
).is_admin
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrapped_view(**kwargs):
|
||||||
|
auth = request.authorization
|
||||||
|
if not (auth and check_auth(auth.username, auth.password)):
|
||||||
|
return ('Unauthorized', 401, {
|
||||||
|
'WWW-Authenticate': 'Basic realm="Login Required"'
|
||||||
|
})
|
||||||
|
return f(**kwargs)
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
|
def review_reports(status, media_type, media_id):
|
||||||
|
(Report
|
||||||
|
.update(status=status)
|
||||||
|
.where((Report.media_type == media_type) & (Report.media_id == media_id))
|
||||||
|
.execute())
|
||||||
|
if status == REPORT_STATUS_ACCEPTED:
|
||||||
|
if media_type == REPORT_MEDIA_USER:
|
||||||
|
user = User[media_id]
|
||||||
|
user.is_disabled = 2
|
||||||
|
user.save()
|
||||||
|
elif media_type == REPORT_MEDIA_MESSAGE:
|
||||||
|
Message.delete().where(Message.id == media_id).execute()
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@admin_required
|
||||||
|
def homepage():
|
||||||
|
return render_template('admin_home.html')
|
||||||
|
|
||||||
|
@bp.route('/reports')
|
||||||
|
@admin_required
|
||||||
|
def reports():
|
||||||
|
return object_list('admin_reports.html', Report.select().order_by(Report.created_date.desc()), 'report_list', report_reasons=dict(report_reasons))
|
||||||
|
|
||||||
|
@bp.route('/reports/<int:id>', methods=['GET', 'POST'])
|
||||||
|
@admin_required
|
||||||
|
def reports_detail(id):
|
||||||
|
report = Report[id]
|
||||||
|
if request.method == 'POST':
|
||||||
|
if request.form.get('take_down'):
|
||||||
|
review_reports(REPORT_STATUS_ACCEPTED, report.media_type, report.media_id)
|
||||||
|
elif request.form.get('discard'):
|
||||||
|
review_reports(REPORT_STATUS_DECLINED, report.media_type, report.media_id)
|
||||||
|
return redirect(url_for('admin.reports'))
|
||||||
|
return render_template('admin_report_detail.html', report=report, report_reasons=dict(report_reasons))
|
||||||
|
|
@ -241,14 +241,15 @@ def relationships_unfollow(self, userid):
|
||||||
@validate_access
|
@validate_access
|
||||||
def profile_search(self):
|
def profile_search(self):
|
||||||
data = request.get_json(True)
|
data = request.get_json(True)
|
||||||
query = User.select().where(User.username ** ('%' + data['q'] + '%')).limit(20)
|
query = User.select().where((User.username ** ('%' + data['q'] + '%')) |
|
||||||
|
(User.full_name ** ('%' + data['q'] + '%'))).limit(20)
|
||||||
results = []
|
results = []
|
||||||
for result in query:
|
for result in query:
|
||||||
profile = result.profile
|
profile = result.profile
|
||||||
results.append({
|
results.append({
|
||||||
"id": result.id,
|
"id": result.id,
|
||||||
"username": result.username,
|
"username": result.username,
|
||||||
"full_name": profile.full_name,
|
"full_name": result.full_name,
|
||||||
"followers_count": len(result.followers())
|
"followers_count": len(result.followers())
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -199,10 +199,60 @@ class Notification(BaseModel):
|
||||||
pub_date = DateTimeField()
|
pub_date = DateTimeField()
|
||||||
seen = IntegerField(default=0)
|
seen = IntegerField(default=0)
|
||||||
|
|
||||||
|
REPORT_MEDIA_USER = 1
|
||||||
|
REPORT_MEDIA_MESSAGE = 2
|
||||||
|
|
||||||
|
REPORT_REASON_SPAM = 1
|
||||||
|
REPORT_REASON_IMPERSONATION = 2
|
||||||
|
REPORT_REASON_PORN = 3
|
||||||
|
REPORT_REASON_VIOLENCE = 4
|
||||||
|
REPORT_REASON_HATE = 5
|
||||||
|
REPORT_REASON_BULLYING = 6
|
||||||
|
REPORT_REASON_SELFINJURY = 7
|
||||||
|
REPORT_REASON_FIREARMS = 8
|
||||||
|
REPORT_REASON_DRUGS = 9
|
||||||
|
REPORT_REASON_UNDERAGE = 10
|
||||||
|
|
||||||
|
report_reasons = [
|
||||||
|
(REPORT_REASON_SPAM, "It's spam"),
|
||||||
|
(REPORT_REASON_IMPERSONATION, "This profile is pretending to be someone else"),
|
||||||
|
(REPORT_REASON_PORN, "Nudity or pornography"),
|
||||||
|
(REPORT_REASON_VIOLENCE, "Violence or dangerous organization"),
|
||||||
|
(REPORT_REASON_HATE, "Hate speech or symbols"),
|
||||||
|
(REPORT_REASON_BULLYING, "Harassment or bullying"),
|
||||||
|
(REPORT_REASON_SELFINJURY, "Self injury"),
|
||||||
|
(REPORT_REASON_FIREARMS, "Sale or promotion of firearms"),
|
||||||
|
(REPORT_REASON_DRUGS, "Sale or promotion of drugs"),
|
||||||
|
(REPORT_REASON_UNDERAGE, "This user is less than 13 years old"),
|
||||||
|
]
|
||||||
|
|
||||||
|
REPORT_STATUS_DELIVERED = 0
|
||||||
|
REPORT_STATUS_ACCEPTED = 1
|
||||||
|
REPORT_STATUS_DECLINED = 2
|
||||||
|
|
||||||
|
# New in 0.8.
|
||||||
|
class Report(BaseModel):
|
||||||
|
media_type = IntegerField()
|
||||||
|
media_id = IntegerField()
|
||||||
|
sender = ForeignKeyField(User, null=True)
|
||||||
|
reason = IntegerField()
|
||||||
|
status = IntegerField(default=REPORT_STATUS_DELIVERED)
|
||||||
|
created_date = DateTimeField()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def media(self):
|
||||||
|
try:
|
||||||
|
if self.media_type == REPORT_MEDIA_USER:
|
||||||
|
return User[self.media_id]
|
||||||
|
elif self.media_type == REPORT_MEDIA_MESSAGE:
|
||||||
|
return Message[self.media_id]
|
||||||
|
except DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
def create_tables():
|
def create_tables():
|
||||||
with database:
|
with database:
|
||||||
database.create_tables([
|
database.create_tables([
|
||||||
User, UserAdminship, UserProfile, Message, Relationship,
|
User, UserAdminship, UserProfile, Message, Relationship,
|
||||||
Upload, Notification])
|
Upload, Notification, Report])
|
||||||
if not os.path.isdir(UPLOAD_DIRECTORY):
|
if not os.path.isdir(UPLOAD_DIRECTORY):
|
||||||
os.makedirs(UPLOAD_DIRECTORY)
|
os.makedirs(UPLOAD_DIRECTORY)
|
||||||
|
|
|
||||||
42
app/reports.py
Normal file
42
app/reports.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
'''
|
||||||
|
Module for user and message reports.
|
||||||
|
|
||||||
|
New in 0.8.
|
||||||
|
'''
|
||||||
|
|
||||||
|
from flask import Blueprint, redirect, request, render_template, url_for
|
||||||
|
from .models import Report, REPORT_MEDIA_USER, REPORT_MEDIA_MESSAGE, report_reasons
|
||||||
|
from .utils import get_current_user
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
bp = Blueprint('reports', __name__, url_prefix='/report')
|
||||||
|
|
||||||
|
@bp.route('/user/<int:userid>', methods=['GET', 'POST'])
|
||||||
|
def report_user(userid):
|
||||||
|
if request.method == "POST":
|
||||||
|
Report.create(
|
||||||
|
media_type=REPORT_MEDIA_USER,
|
||||||
|
media_id=userid,
|
||||||
|
sender=get_current_user(),
|
||||||
|
reason=request.form['reason'],
|
||||||
|
created_date=datetime.datetime.now()
|
||||||
|
)
|
||||||
|
return redirect(url_for('reports.report_done'))
|
||||||
|
return render_template('report_user.html', report_reasons=report_reasons)
|
||||||
|
|
||||||
|
@bp.route('/message/<int:userid>', methods=['GET', 'POST'])
|
||||||
|
def report_message(userid):
|
||||||
|
if request.method == "POST":
|
||||||
|
Report.create(
|
||||||
|
media_type=REPORT_MEDIA_MESSAGE,
|
||||||
|
media_id=userid,
|
||||||
|
sender=get_current_user(),
|
||||||
|
reason=request.form['reason'],
|
||||||
|
created_date=datetime.datetime.now()
|
||||||
|
)
|
||||||
|
return redirect(url_for('reports.report_done'))
|
||||||
|
return render_template('report_message.html', report_reasons=report_reasons)
|
||||||
|
|
||||||
|
@bp.route('/done', methods=['GET', 'POST'])
|
||||||
|
def report_done():
|
||||||
|
return render_template('report_done.html')
|
||||||
28
app/templates/admin_base.html
Normal file
28
app/templates/admin_base.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>{{ site_name }}</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/static/style.css">
|
||||||
|
<style>.done{opacity:.5}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1><a href="{{ url_for('admin.homepage') }}">{{ site_name }} Admin</a></h1>
|
||||||
|
<div class="metanav">
|
||||||
|
<!-- what does it go here? -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
{% for message in get_flashed_messages() %}
|
||||||
|
<div class="flash">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p class="copyright">© 2019 Sakuragasaki46.
|
||||||
|
<a href="/about/">About</a> - <a href="/terms/">Terms</a> -
|
||||||
|
<a href="/privacy/">Privacy</a></p>
|
||||||
|
</div>
|
||||||
|
<script src="/static/lib.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
app/templates/admin_home.html
Normal file
9
app/templates/admin_home.html
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{% extends "admin_base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="{{ url_for('admin.reports') }}">Reports</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
27
app/templates/admin_report_detail.html
Normal file
27
app/templates/admin_report_detail.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{% extends "admin_base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h2>Report detail #{{ report.id }}</h2>
|
||||||
|
<p>Type: {{ [None, 'user', 'message'][report.media_type] }}</p>
|
||||||
|
<p>Reason: <strong>{{ report_reasons[report.reason] }}</strong></p>
|
||||||
|
<p>Status: <strong>{{ ['Unreviewed', 'Accepted', 'Declined'][report.status] }}</strong></p>
|
||||||
|
|
||||||
|
<h3>Detail</h3>
|
||||||
|
{% if report.media is none %}
|
||||||
|
<p><em>The media is unavailable.</em></p>
|
||||||
|
{% elif report.media_type == 1 %}
|
||||||
|
<p><em>Showing first 20 messages of the reported user.</em></p>
|
||||||
|
<ul>
|
||||||
|
{% for message in report.media.messages %}
|
||||||
|
{% include "includes/reported_message.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% elif report.media_type == 2 %}
|
||||||
|
{% set message = report.media %}
|
||||||
|
{% include "includes/reported_message.html" %}
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST">
|
||||||
|
<input type="submit" name="take_down" value="Take down">
|
||||||
|
<input type="submit" name="discard" value="Discard">
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
16
app/templates/admin_reports.html
Normal file
16
app/templates/admin_reports.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends "admin_base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<ul>
|
||||||
|
{% for report in report_list %}
|
||||||
|
<li {% if report.status > 0 %}class="done"{% endif %}>
|
||||||
|
<p><strong>#{{ report.id }}</strong>
|
||||||
|
(<a href="{{ url_for('admin.reports_detail', id=report.id) }}">detail</a>)</p>
|
||||||
|
<p>Type: {{ [None, 'user', 'message'][report.media_type] }}</p>
|
||||||
|
<p>Reason: <strong>{{ report_reasons[report.reason] }}</strong></p>
|
||||||
|
<p>Status: <strong>{{ ['Unreviewed', 'Accepted', 'Declined'][report.status] }}</strong></p>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% include "includes/pagination.html" %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -22,6 +22,6 @@
|
||||||
<li><a href="/edit/{{ message.id }}">Edit or change privacy</a></li>
|
<li><a href="/edit/{{ message.id }}">Edit or change privacy</a></li>
|
||||||
<li><a href="/delete/{{ message.id }}">Delete permanently</a></li>
|
<li><a href="/delete/{{ message.id }}">Delete permanently</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!--li><a href="/report/{{ message.id }}">Report</a></li-->
|
<li><a href="/report/message/{{ message.id }}" target="_blank">Report</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
15
app/templates/includes/reported_message.html
Normal file
15
app/templates/includes/reported_message.html
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<div>
|
||||||
|
<p><strong>Message #{{ message.id }}</strong> (<a href="{# url_for('admin.message_info', id=message.id) #}">detail</a>)</p>
|
||||||
|
<p>Author: <a href="{# url_for('admin.user_detail', id=message.user_id #}">{{ message.user.username }}</a></p>
|
||||||
|
<p>Text:</p>
|
||||||
|
<div style="border:1px solid gray;padding:12px">
|
||||||
|
{{ message.text|enrich }}
|
||||||
|
{% if message.uploads %}
|
||||||
|
<div>
|
||||||
|
<img src="/uploads/{{ message.uploads[0].filename() }}">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p>Privacy: {{ ['public', 'unlisted', 'friends', 'only me'][message.privacy] }}</p>
|
||||||
|
<p>Date: {{ message.pub_date.strftime('%B %-d, %Y %H:%M:%S') }}</p>
|
||||||
|
</div>
|
||||||
27
app/templates/report_base.html
Normal file
27
app/templates/report_base.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Report – Cori+</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<style>
|
||||||
|
body{margin:0;font-family:sans-serif}
|
||||||
|
.item{padding:12px;border-bottom:1px solid gray}
|
||||||
|
.item h2{font-size:1em;margin:6px 0}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="content">
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<form id="reportForm" method="POST">
|
||||||
|
<input id="reportFormValue" name="reason" type="hidden" value="">
|
||||||
|
<input type="submit" style="display:none">
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
function submitReport(value){
|
||||||
|
reportFormValue.value = value;
|
||||||
|
reportForm.submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
11
app/templates/report_done.html
Normal file
11
app/templates/report_done.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{% extends "report_base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="item">
|
||||||
|
<h1>Done</h1>
|
||||||
|
|
||||||
|
<p>Your report has been sent.<br />
|
||||||
|
We'll review the user or message, and, if against our Community
|
||||||
|
Guidelines, we'll remove it.</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
11
app/templates/report_message.html
Normal file
11
app/templates/report_message.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{% extends "report_base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% for reason in report_reasons %}
|
||||||
|
<form method="POST">
|
||||||
|
<div class="item" onclick="submitReport({{ reason[0] }})">
|
||||||
|
<h2>{{ reason[1] }}</h2>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
11
app/templates/report_user.html
Normal file
11
app/templates/report_user.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{% extends "report_base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% for reason in report_reasons %}
|
||||||
|
<form method="POST">
|
||||||
|
<div class="item" onclick="submitReport({{ reason[0] }})">
|
||||||
|
<h2>{{ reason[1] }}</h2>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
|
User-Agent: *
|
||||||
|
Disallow: /report/
|
||||||
|
Noindex: /admin/
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue