more managed ips work: cli sql improvements, added admin panel
This commit is contained in:
		@ -61,6 +61,7 @@ app.config.from_mapping(
 | 
			
		||||
  MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", default=""),
 | 
			
		||||
  MAIL_DEFAULT_SENDER=os.environ.get("MAIL_DEFAULT_SENDER", default="no-reply@capsul.org"),
 | 
			
		||||
  ADMIN_EMAIL_ADDRESSES=os.environ.get("ADMIN_EMAIL_ADDRESSES", default="ops@cyberia.club"),
 | 
			
		||||
  ADMIN_PANEL_ALLOW_EMAIL_ADDRESSES=os.environ.get("ADMIN_PANEL_ALLOW_EMAIL_ADDRESSES", default="forest.n.johnson@gmail.com,capsul@cyberia.club"),
 | 
			
		||||
 | 
			
		||||
  PROMETHEUS_URL=os.environ.get("PROMETHEUS_URL", default="https://prometheus.cyberia.club"),
 | 
			
		||||
 | 
			
		||||
@ -143,6 +144,12 @@ try:
 | 
			
		||||
except:
 | 
			
		||||
  app.logger.warning("unable to create btcpay client. Capsul will work fine except cryptocurrency payments will not work. The error was: " + my_exec_info_message(sys.exc_info()))
 | 
			
		||||
 | 
			
		||||
# only start the scheduler and attempt to migrate the database if we are running the app.
 | 
			
		||||
# otherwise we are running a CLI command.
 | 
			
		||||
command_line = ' '.join(sys.argv)
 | 
			
		||||
is_running_server = ('flask run' in command_line) or ('gunicorn' in command_line)
 | 
			
		||||
 | 
			
		||||
app.logger.info(f"is_running_server: {is_running_server}")
 | 
			
		||||
 | 
			
		||||
if app.config['HUB_MODE_ENABLED']:
 | 
			
		||||
 | 
			
		||||
@ -151,7 +158,7 @@ if app.config['HUB_MODE_ENABLED']:
 | 
			
		||||
 | 
			
		||||
    # debug mode (flask reloader) runs two copies of the app. When running in debug mode,
 | 
			
		||||
    # we only want to start the scheduler one time.
 | 
			
		||||
    if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
 | 
			
		||||
    if is_running_server and (not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true'):
 | 
			
		||||
      scheduler = BackgroundScheduler()
 | 
			
		||||
      heartbeat_task_url = f"{app.config['HUB_URL']}/hub/heartbeat-task"
 | 
			
		||||
      heartbeat_task_headers = {'Authorization': f"Bearer {app.config['HUB_TOKEN']}"}
 | 
			
		||||
@ -163,11 +170,11 @@ if app.config['HUB_MODE_ENABLED']:
 | 
			
		||||
 | 
			
		||||
  else:
 | 
			
		||||
    app.config['HUB_MODEL'] = hub_model.MockHub()
 | 
			
		||||
    
 | 
			
		||||
  from capsulflask import db
 | 
			
		||||
  db.init_app(app)
 | 
			
		||||
 | 
			
		||||
  from capsulflask import auth, landing, console, payment, metrics, cli, hub_api
 | 
			
		||||
  from capsulflask import db
 | 
			
		||||
  db.init_app(app, is_running_server)
 | 
			
		||||
 | 
			
		||||
  from capsulflask import auth, landing, console, payment, metrics, cli, hub_api, admin
 | 
			
		||||
 | 
			
		||||
  app.register_blueprint(landing.bp)
 | 
			
		||||
  app.register_blueprint(auth.bp)
 | 
			
		||||
@ -176,6 +183,7 @@ if app.config['HUB_MODE_ENABLED']:
 | 
			
		||||
  app.register_blueprint(metrics.bp)
 | 
			
		||||
  app.register_blueprint(cli.bp)
 | 
			
		||||
  app.register_blueprint(hub_api.bp)
 | 
			
		||||
  app.register_blueprint(admin.bp)
 | 
			
		||||
 | 
			
		||||
  app.add_url_rule("/", endpoint="index")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -3,16 +3,9 @@ import sys
 | 
			
		||||
import json
 | 
			
		||||
import ipaddress
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
from flask import Blueprint
 | 
			
		||||
from flask import flash
 | 
			
		||||
from flask import current_app
 | 
			
		||||
from flask import g
 | 
			
		||||
from flask import request
 | 
			
		||||
from flask import session
 | 
			
		||||
from flask import render_template
 | 
			
		||||
from flask import redirect
 | 
			
		||||
from flask import url_for
 | 
			
		||||
from flask import Blueprint, current_app, render_template, make_response
 | 
			
		||||
from werkzeug.exceptions import abort
 | 
			
		||||
from nanoid import generate
 | 
			
		||||
 | 
			
		||||
from capsulflask.metrics import durations as metric_durations
 | 
			
		||||
from capsulflask.auth import admin_account_required
 | 
			
		||||
@ -25,18 +18,23 @@ bp = Blueprint("admin", __name__, url_prefix="/admin")
 | 
			
		||||
@admin_account_required
 | 
			
		||||
def index():
 | 
			
		||||
  hosts = get_model().list_hosts_with_networks()
 | 
			
		||||
  vms = get_model().all_non_deleted_vms()
 | 
			
		||||
  operations = get_model().list_all_operations()
 | 
			
		||||
  vms_by_host_and_network = get_model().all_non_deleted_vms_by_host_and_network()
 | 
			
		||||
  network_display_width_px = float(250);
 | 
			
		||||
  #operations = get_model().list_all_operations()
 | 
			
		||||
 | 
			
		||||
  display_hosts = []
 | 
			
		||||
  inline_styles = [f"""
 | 
			
		||||
  .network-display {'{'}
 | 
			
		||||
    width: {network_display_width_px}px;
 | 
			
		||||
  {'}'}
 | 
			
		||||
  """]
 | 
			
		||||
 | 
			
		||||
  for kv in hosts.items():
 | 
			
		||||
    name = kv[0]
 | 
			
		||||
    host_id = kv[0]
 | 
			
		||||
    value = kv[1]
 | 
			
		||||
    
 | 
			
		||||
    for network in value['networks']:
 | 
			
		||||
      network["network_name"]
 | 
			
		||||
    display_host = dict(name=host_id, networks=value['networks'])
 | 
			
		||||
 | 
			
		||||
    for network in display_host['networks']:
 | 
			
		||||
      ipv4_network = ipaddress.ip_network(network["public_ipv4_cidr_block"], False)
 | 
			
		||||
      network_start_int = -1
 | 
			
		||||
      network_end_int = -1
 | 
			
		||||
@ -48,10 +46,42 @@ def index():
 | 
			
		||||
            network_start_int = int(ipv4_address)
 | 
			
		||||
          
 | 
			
		||||
          network_end_int = int(ipv4_address)
 | 
			
		||||
 | 
			
		||||
      
 | 
			
		||||
      network['allocations'] = []
 | 
			
		||||
      network_addresses_width = float((network_end_int-network_start_int)+1)
 | 
			
		||||
 | 
			
		||||
    display_hosts.append(dict(name=name, last_health_check=value['last_health_check']))
 | 
			
		||||
  
 | 
			
		||||
      if host_id in vms_by_host_and_network:
 | 
			
		||||
        if network['network_name'] in vms_by_host_and_network[host_id]:
 | 
			
		||||
          for vm in vms_by_host_and_network[host_id][network['network_name']]:
 | 
			
		||||
            ip_address_int = int(ipaddress.ip_address(vm['public_ipv4']))
 | 
			
		||||
            if network_start_int < ip_address_int and ip_address_int < network_end_int:
 | 
			
		||||
              allocation = f"{host_id}_{network['network_name']}_{len(network['allocations'])}"
 | 
			
		||||
              inline_styles.append(
 | 
			
		||||
                f"""
 | 
			
		||||
                .{allocation} {'{'}
 | 
			
		||||
                  left: {(float(ip_address_int-network_start_int)/network_addresses_width)*network_display_width_px}px; 
 | 
			
		||||
                  width: {network_display_width_px/network_addresses_width}px;
 | 
			
		||||
                {'}'}
 | 
			
		||||
                """
 | 
			
		||||
              )
 | 
			
		||||
              network['allocations'].append(allocation)
 | 
			
		||||
            else:
 | 
			
		||||
              current_app.logger.warning(f"/admin: capsul {vm['id']} has public_ipv4 {vm['public_ipv4']} which is out of range for its host network {host_id} {network['network_name']} {network['public_ipv4_cidr_block']}")
 | 
			
		||||
 | 
			
		||||
  return render_template("admin.html", vms=mappedVms, has_vms=len(vms) > 0, created=created)
 | 
			
		||||
    display_hosts.append(display_host)
 | 
			
		||||
 | 
			
		||||
  csp_inline_style_nonce = generate(alphabet="1234567890qwertyuiopasdfghjklzxcvbnm", size=10)
 | 
			
		||||
  response_text = render_template(
 | 
			
		||||
    "admin.html", 
 | 
			
		||||
    display_hosts=display_hosts, 
 | 
			
		||||
    network_display_width_px=network_display_width_px,
 | 
			
		||||
    csp_inline_style_nonce=csp_inline_style_nonce,
 | 
			
		||||
    inline_style='\n'.join(inline_styles)
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  response = make_response(response_text)
 | 
			
		||||
 | 
			
		||||
  response.headers.set('Content-Type', 'text/html')
 | 
			
		||||
  response.headers.set('Content-Security-Policy', f"default-src 'self'; style-src 'self' 'nonce-{csp_inline_style_nonce}'")
 | 
			
		||||
 | 
			
		||||
  return response
 | 
			
		||||
 | 
			
		||||
@ -40,7 +40,7 @@ def admin_account_required(view):
 | 
			
		||||
        if session.get("account") is None or session.get("csrf-token") is None:
 | 
			
		||||
            return redirect(url_for("auth.login"))
 | 
			
		||||
 | 
			
		||||
        if session.get("account") not in current_app.config["ADMIN_EMAIL_ADDRESSES_CSV"].split(","):
 | 
			
		||||
        if session.get("account") not in current_app.config["ADMIN_PANEL_ALLOW_EMAIL_ADDRESSES"].split(","):
 | 
			
		||||
            return redirect(url_for("auth.login"))
 | 
			
		||||
 | 
			
		||||
        return view(**kwargs)
 | 
			
		||||
 | 
			
		||||
@ -23,9 +23,9 @@ from capsulflask import cli
 | 
			
		||||
 | 
			
		||||
bp = Blueprint("console", __name__, url_prefix="/console")
 | 
			
		||||
 | 
			
		||||
def makeCapsulId():
 | 
			
		||||
  lettersAndNumbers = generate(alphabet="1234567890qwertyuiopasdfghjklzxcvbnm", size=10)
 | 
			
		||||
  return f"capsul-{lettersAndNumbers}"
 | 
			
		||||
def make_capsul_id():
 | 
			
		||||
  letters_n_nummers = generate(alphabet="1234567890qwertyuiopasdfghjklzxcvbnm", size=10)
 | 
			
		||||
  return f"capsul-{letters_n_nummers}"
 | 
			
		||||
 | 
			
		||||
def double_check_capsul_address(id, ipv4, get_ssh_host_keys):
 | 
			
		||||
  try:
 | 
			
		||||
@ -244,7 +244,7 @@ def create():
 | 
			
		||||
      """)
 | 
			
		||||
 | 
			
		||||
    if len(errors) == 0:
 | 
			
		||||
      id = makeCapsulId()
 | 
			
		||||
      id = make_capsul_id()
 | 
			
		||||
      get_model().create_vm(
 | 
			
		||||
        email=session["account"], 
 | 
			
		||||
        id=id, 
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@ from flask import g
 | 
			
		||||
from capsulflask.db_model import DBModel
 | 
			
		||||
from capsulflask.shared import my_exec_info_message
 | 
			
		||||
 | 
			
		||||
def init_app(app):
 | 
			
		||||
def init_app(app, is_running_server):
 | 
			
		||||
 | 
			
		||||
  app.config['PSYCOPG2_CONNECTION_POOL'] = psycopg2.pool.SimpleConnectionPool(
 | 
			
		||||
    1,
 | 
			
		||||
@ -18,6 +18,14 @@ def init_app(app):
 | 
			
		||||
    app.config['POSTGRES_CONNECTION_PARAMETERS']
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  # tell the app to clean up the DB connection when shutting down.
 | 
			
		||||
  app.teardown_appcontext(close_db)
 | 
			
		||||
 | 
			
		||||
  # only run the migrations if we are running the server. 
 | 
			
		||||
  # If we are just running a cli command (e.g. to fix a broken migration 😅), skip it
 | 
			
		||||
  if not is_running_server:
 | 
			
		||||
    return
 | 
			
		||||
 | 
			
		||||
  schemaMigrations = {}
 | 
			
		||||
  schemaMigrationsPath = join(app.root_path, 'schema_migrations')
 | 
			
		||||
  app.logger.info("loading schema migration scripts from {}".format(schemaMigrationsPath))
 | 
			
		||||
@ -35,7 +43,7 @@ def init_app(app):
 | 
			
		||||
  hasSchemaVersionTable = False
 | 
			
		||||
  actionWasTaken = False
 | 
			
		||||
  schemaVersion = 0
 | 
			
		||||
  desiredSchemaVersion = 15
 | 
			
		||||
  desiredSchemaVersion = 16
 | 
			
		||||
 | 
			
		||||
  cursor = connection.cursor()
 | 
			
		||||
 | 
			
		||||
@ -103,7 +111,7 @@ def init_app(app):
 | 
			
		||||
    ("schema migration completed." if actionWasTaken else "schema is already up to date. "), schemaVersion
 | 
			
		||||
  ))
 | 
			
		||||
 | 
			
		||||
  app.teardown_appcontext(close_db)
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_model():
 | 
			
		||||
 | 
			
		||||
@ -56,9 +56,23 @@ class DBModel:
 | 
			
		||||
 | 
			
		||||
  #     ------    VM & ACCOUNT MANAGEMENT     ---------  
 | 
			
		||||
 | 
			
		||||
  def all_non_deleted_vms(self):
 | 
			
		||||
    self.cursor.execute("SELECT id, host, network_name, last_seen_ipv4, last_seen_ipv6 FROM vms WHERE deleted IS NULL")
 | 
			
		||||
    return list(map(lambda x: dict(id=x[0], host=x[1], network_name=x[2], last_seen_ipv4=x[3], last_seen_ipv6=x[4]), self.cursor.fetchall()))
 | 
			
		||||
  def all_non_deleted_vms_by_host_and_network(self):
 | 
			
		||||
    self.cursor.execute("SELECT id, host, network_name, public_ipv4, public_ipv6 FROM vms WHERE deleted IS NULL")
 | 
			
		||||
 | 
			
		||||
    hosts = dict()
 | 
			
		||||
    for row in self.cursor.fetchall():
 | 
			
		||||
      host_id = row[1]
 | 
			
		||||
      network_name = row[2]
 | 
			
		||||
      if host_id not in hosts:
 | 
			
		||||
        hosts[host_id] = dict()
 | 
			
		||||
      if network_name not in hosts[host_id]:
 | 
			
		||||
        hosts[host_id][network_name] = []
 | 
			
		||||
      
 | 
			
		||||
      hosts[host_id][network_name].append(
 | 
			
		||||
        dict(id=row[0], public_ipv4=row[3], public_ipv6=row[4])
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
    return hosts
 | 
			
		||||
 | 
			
		||||
  def all_non_deleted_vm_ids(self):
 | 
			
		||||
    self.cursor.execute("SELECT id FROM vms WHERE deleted IS NULL")
 | 
			
		||||
@ -108,7 +122,7 @@ class DBModel:
 | 
			
		||||
 | 
			
		||||
  def list_vms_for_account(self, email):
 | 
			
		||||
    self.cursor.execute(""" 
 | 
			
		||||
      SELECT vms.id, vms.last_seen_ipv4, vms.last_seen_ipv6, vms.size, vms.os, vms.created, vms.deleted, vm_sizes.dollars_per_month
 | 
			
		||||
      SELECT vms.id, vms.public_ipv4, vms.public_ipv6, vms.size, vms.os, vms.created, vms.deleted, vm_sizes.dollars_per_month
 | 
			
		||||
        FROM vms JOIN vm_sizes on vms.size = vm_sizes.id
 | 
			
		||||
      WHERE vms.email = %s""", 
 | 
			
		||||
      (email, )
 | 
			
		||||
@ -119,7 +133,7 @@ class DBModel:
 | 
			
		||||
    ))
 | 
			
		||||
 | 
			
		||||
  def update_vm_ip(self, email, id, ipv4):
 | 
			
		||||
    self.cursor.execute("UPDATE vms SET last_seen_ipv4 = %s WHERE email = %s AND id = %s", (ipv4, email, id))
 | 
			
		||||
    self.cursor.execute("UPDATE vms SET public_ipv4 = %s WHERE email = %s AND id = %s", (ipv4, email, id))
 | 
			
		||||
    self.connection.commit()
 | 
			
		||||
 | 
			
		||||
  def update_vm_ssh_host_keys(self, email, id, ssh_host_keys):
 | 
			
		||||
@ -155,7 +169,7 @@ class DBModel:
 | 
			
		||||
 | 
			
		||||
  def get_vm_detail(self, email, id):
 | 
			
		||||
    self.cursor.execute(""" 
 | 
			
		||||
      SELECT vms.id, vms.last_seen_ipv4, vms.last_seen_ipv6, os_images.description, vms.created, vms.deleted,
 | 
			
		||||
      SELECT vms.id, vms.public_ipv4, vms.public_ipv6, os_images.description, vms.created, vms.deleted,
 | 
			
		||||
            vm_sizes.id, vm_sizes.dollars_per_month, vm_sizes.vcpus, vm_sizes.memory_mb, vm_sizes.bandwidth_gb_per_month
 | 
			
		||||
      FROM vms 
 | 
			
		||||
        JOIN os_images on vms.os = os_images.id
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
 | 
			
		||||
DROP TABLE host_network;
 | 
			
		||||
 | 
			
		||||
ALTER TABLE vms DROP COLUMN network;
 | 
			
		||||
ALTER TABLE vms DROP COLUMN network_name;
 | 
			
		||||
 | 
			
		||||
UPDATE schemaversion SET version = 15;
 | 
			
		||||
 | 
			
		||||
@ -1,14 +1,25 @@
 | 
			
		||||
 | 
			
		||||
CREATE TABLE host_network (
 | 
			
		||||
  public_ipv4_cidr_block  TEXT PRIMARY KEY NOT NULL,
 | 
			
		||||
  public_ipv4_cidr_block  TEXT NOT NULL,
 | 
			
		||||
  network_name       TEXT NOT NULL,
 | 
			
		||||
  host               TEXT NOT NULL REFERENCES hosts(id) ON DELETE RESTRICT,
 | 
			
		||||
  PRIMARY KEY (host, network_name)
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
INSERT INTO host_network (public_ipv4_cidr_block, network_name, host) VALUES ('baikal', 'virbr1', '69.61.2.162/27'),
 | 
			
		||||
                                                                        ('baikal', 'virbr2', '69.61.2.194/26');
 | 
			
		||||
 | 
			
		||||
ALTER TABLE vms ADD COLUMN network_name TEXT NOT NULL;
 | 
			
		||||
INSERT INTO host_network (host, network_name, public_ipv4_cidr_block) VALUES ('baikal', 'virbr1', '69.61.2.162/27'),
 | 
			
		||||
                                                                             ('baikal', 'virbr2', '69.61.2.194/26');
 | 
			
		||||
ALTER TABLE vms RENAME COLUMN last_seen_ipv4 TO public_ipv4;
 | 
			
		||||
ALTER TABLE vms RENAME COLUMN last_seen_ipv6 TO public_ipv6;
 | 
			
		||||
ALTER TABLE vms ADD COLUMN network_name TEXT;
 | 
			
		||||
 | 
			
		||||
UPDATE vms SET network_name = 'virbr1' WHERE public_ipv6 < '69.61.2.192';
 | 
			
		||||
UPDATE vms SET network_name = 'virbr2' WHERE public_ipv6 >= '69.61.2.192';
 | 
			
		||||
 | 
			
		||||
ALTER TABLE vms ALTER COLUMN network_name SET NOT NULL;
 | 
			
		||||
 | 
			
		||||
ALTER TABLE vms ADD FOREIGN KEY (host, network_name) REFERENCES host_network(host, network_name) ON DELETE RESTRICT;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
UPDATE schemaversion SET version = 16;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -356,3 +356,26 @@ footer {
 | 
			
		||||
  font-size: 0.8em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.network-row {
 | 
			
		||||
  background-color: #777e7350;
 | 
			
		||||
  padding: 5px;
 | 
			
		||||
  margin-top: 5px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.network-display {
 | 
			
		||||
  height: 1em;
 | 
			
		||||
  border: 1px solid #777e73;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.network-display div {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.network-display div div {
 | 
			
		||||
  top: 1px;
 | 
			
		||||
  height: calc(1em - 2px);
 | 
			
		||||
  background-color: rgba(221, 169, 56, 0.8);
 | 
			
		||||
  border: 1px solid rgba(255, 223, 155, 0.8);
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										42
									
								
								capsulflask/templates/admin.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								capsulflask/templates/admin.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,42 @@
 | 
			
		||||
{% extends 'base.html' %}
 | 
			
		||||
 | 
			
		||||
{% block title %}Capsul Admin{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
<style nonce="{{csp_inline_style_nonce}}">
 | 
			
		||||
{{inline_style}}
 | 
			
		||||
</style>
 | 
			
		||||
<div class="row third-margin">
 | 
			
		||||
  <h1>Capsul Admin</h1>
 | 
			
		||||
</div>
 | 
			
		||||
<div class="third-margin">
 | 
			
		||||
  {% for display_host in display_hosts %}
 | 
			
		||||
    <div class="row">
 | 
			
		||||
      <h1>{{ display_host["name"] }}</h1>
 | 
			
		||||
    </div>
 | 
			
		||||
    {% for network in display_host["networks"] %}
 | 
			
		||||
      <div class="row network-row">
 | 
			
		||||
        <i>{{ network["network_name"] }}</i> 
 | 
			
		||||
        <span>{{ network["public_ipv4_cidr_block"] }}</span>
 | 
			
		||||
        <div class="network-display">
 | 
			
		||||
          {% for allocation in network["allocations"] %}
 | 
			
		||||
 | 
			
		||||
            {# This outer div is used as an abs position container & selected by CSS so don't remove it pls. #}
 | 
			
		||||
            <div> 
 | 
			
		||||
 | 
			
		||||
            <div class="{{allocation}}">
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
    
 | 
			
		||||
    <hr/>
 | 
			
		||||
  {% endfor %}
 | 
			
		||||
</div>
 | 
			
		||||
{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block pagesource %}/templates/admin.html{% endblock %}
 | 
			
		||||
		Reference in New Issue
	
	Block a user