initial commit with basic user/token management
This commit is contained in:
commit
62321d8b3c
238
app.py
Normal file
238
app.py
Normal file
|
@ -0,0 +1,238 @@
|
||||||
|
import os
|
||||||
|
from flask import Flask, render_template, request, flash, redirect, session
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms.fields.html5 import DateField, EmailField
|
||||||
|
from wtforms.fields import StringField, BooleanField
|
||||||
|
from wtforms.validators import DataRequired, ValidationError
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_security import Security, SQLAlchemyUserDatastore, auth_required, hash_password
|
||||||
|
from flask_security.models import fsqla_v2 as fsqla
|
||||||
|
from flask_security.forms import LoginForm, Required, PasswordField
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from door import Door
|
||||||
|
|
||||||
|
door = Door('10.10.21.2')
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Generate a nice key using secrets.token_urlsafe()
|
||||||
|
app.config['SECRET_KEY'] = os.environ.get("SECRET_KEY", 'Q7PJu2fg2jabYwP-Psop6c6f2G4')
|
||||||
|
# Bcrypt is set as default SECURITY_PASSWORD_HASH, which requires a salt
|
||||||
|
# Generate a good salt using: secrets.SystemRandom().getrandbits(128)
|
||||||
|
app.config['SECURITY_PASSWORD_SALT'] = os.environ.get("SECURITY_PASSWORD_SALT", '10036796768252925167749545152988277953')
|
||||||
|
app.config['SECURITY_USER_IDENTITY_ATTRIBUTES'] = ('username', 'email')
|
||||||
|
|
||||||
|
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite://'
|
||||||
|
# As of Flask-SQLAlchemy 2.4.0 it is easy to pass in options directly to the
|
||||||
|
# underlying engine. This option makes sure that DB connections from the
|
||||||
|
# pool are still valid. Important for entire application since
|
||||||
|
# many DBaaS options automatically close idle connections.
|
||||||
|
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create database connection object
|
||||||
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
|
# Define models
|
||||||
|
fsqla.FsModels.set_db_info(db)
|
||||||
|
|
||||||
|
class Role(db.Model, fsqla.FsRoleMixin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class User(db.Model, fsqla.FsUserMixin):
|
||||||
|
username = db.Column(db.String(255))
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ExtendedLoginForm(LoginForm):
|
||||||
|
email = StringField('Benutzername oder E-Mail', [Required()])
|
||||||
|
password = PasswordField('Passwort', [Required()])
|
||||||
|
|
||||||
|
|
||||||
|
# Setup Flask-Security
|
||||||
|
user_datastore = SQLAlchemyUserDatastore(db, User, Role)
|
||||||
|
security = Security(app, user_datastore, login_form=ExtendedLoginForm)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_valid_thru_date(form, field):
|
||||||
|
if form.limit_validity.data: # only check date format if limited validity of token is set
|
||||||
|
try:
|
||||||
|
if not field.data >= date.today():
|
||||||
|
raise ValueError
|
||||||
|
except ValueError as e:
|
||||||
|
flash("Ungültiges Datum")
|
||||||
|
raise ValidationError
|
||||||
|
return True
|
||||||
|
|
||||||
|
class TokenForm(FlaskForm):
|
||||||
|
name = StringField('Name', validators=[DataRequired()])
|
||||||
|
email = EmailField('E-Mail', validators=[DataRequired()])
|
||||||
|
limit_validity = BooleanField('Gültigkeit begrenzen?')
|
||||||
|
valid_thru = DateField('Gültig bis', validators=[validate_valid_thru_date])
|
||||||
|
active = BooleanField('Aktiv?')
|
||||||
|
dsgvo = BooleanField('Einwilligung DSGVO erfragt?', validators=[DataRequired()])
|
||||||
|
|
||||||
|
|
||||||
|
# Create a user to test with
|
||||||
|
@app.before_first_request
|
||||||
|
def create_user():
|
||||||
|
db.create_all()
|
||||||
|
if not user_datastore.find_user(email='admin@example.com', username="admin"):
|
||||||
|
user_datastore.create_user(email='admin@example.com', username="admin", password=hash_password("password"))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def door_lock():
|
||||||
|
return render_template('index.html', door_state=door.state, encoder_position=door.encoder_position)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/tokens')
|
||||||
|
#@auth_required()
|
||||||
|
def list_tokens():
|
||||||
|
tokens = door.get_tokens()
|
||||||
|
assigned_tokens = {t: data for t, data in tokens.items() if not data['inactive']}
|
||||||
|
inactive_tokens = {t: data for t, data in tokens.items() if data['inactive']}
|
||||||
|
return render_template('tokens.html', assigned_tokens=assigned_tokens, inactive_tokens=inactive_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/register-token', methods=['GET', 'POST'])
|
||||||
|
#@auth_required()
|
||||||
|
def register():
|
||||||
|
"""Register new token for locking and unlocking the door.
|
||||||
|
|
||||||
|
This route displays the most recently scanned invalid token as reported in the logfile and provides a form for
|
||||||
|
entering user info (name, email, valid thru date (optional)) for the new token.
|
||||||
|
|
||||||
|
If the route is called via POST the provided form data is checked and if the check succeeds the /store-token route
|
||||||
|
will be called which adds the new token to the database.
|
||||||
|
"""
|
||||||
|
form = TokenForm()
|
||||||
|
if request.method == 'GET':
|
||||||
|
# set default valid thru date to today to make sure form validity check passes
|
||||||
|
# (will not be used if limited validity is disabled)
|
||||||
|
form.valid_thru.data = date.today()
|
||||||
|
return render_template('register.html', token=door.get_most_recent_token(), form=form)
|
||||||
|
elif request.method == 'POST' and form.validate():
|
||||||
|
# store data in session cookie
|
||||||
|
session['token'] = door.get_most_recent_token()['token']
|
||||||
|
session['name'] = form.name.data
|
||||||
|
session['email'] = form.email.data
|
||||||
|
if form.limit_validity.data:
|
||||||
|
session['valid_thru'] = form.valid_thru.data.isoformat()
|
||||||
|
else:
|
||||||
|
session['valid_thru'] = ''
|
||||||
|
session['inactive'] = not form.active.data
|
||||||
|
return redirect(f'/store-token')
|
||||||
|
else:
|
||||||
|
return render_template('register.html', token=door.get_most_recent_token(), form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/edit-token/<token>', methods=['GET', 'POST'])
|
||||||
|
#@auth_required()
|
||||||
|
def edit_token(token):
|
||||||
|
"""Edit data in the token file (name, email, valid_thru date, active/inactive).
|
||||||
|
|
||||||
|
If the route is accessed via GET it will provide a form for editing the currently stored data for the user.
|
||||||
|
If the route is accessed via POST it will check if the form data is good and then store the modified user data in
|
||||||
|
the database (by redirecting to the /store-token route)
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
token : str
|
||||||
|
The token for which data should be edited.
|
||||||
|
"""
|
||||||
|
form = TokenForm()
|
||||||
|
form.dsgvo.validators = [] # we skip the validation of the DSGVO checkbox here because we assume the user agreed
|
||||||
|
# to it before
|
||||||
|
if request.method == 'GET':
|
||||||
|
tokens = door.get_tokens()
|
||||||
|
if token in tokens:
|
||||||
|
# set default for form according to values from the token file
|
||||||
|
et = tokens[token]
|
||||||
|
form.active.data = not et['inactive']
|
||||||
|
form.name.data = et['name'] if et['name'] else ''
|
||||||
|
form.email.data = et['email'] if et['email'] else ''
|
||||||
|
# for the valid thru date we use today's date in case there is not valid date in the database
|
||||||
|
try:
|
||||||
|
form.valid_thru.data = date.fromisoformat(et['valid_thru'])
|
||||||
|
form.limit_validity.data = True
|
||||||
|
except Exception:
|
||||||
|
form.valid_thru.data = date.today()
|
||||||
|
|
||||||
|
return render_template('edit.html',token=token, form=form)
|
||||||
|
else:
|
||||||
|
# flash an error message if the route is accessed with an invalid token
|
||||||
|
flash(f'Ausgewaehlter Token {token} in Tokenfile nicht gefunden.')
|
||||||
|
return redirect('/tokens')
|
||||||
|
elif request.method == 'POST' and form.validate():
|
||||||
|
# store data in session cookie
|
||||||
|
session['token'] = token
|
||||||
|
session['name'] = form.name.data
|
||||||
|
session['email'] = form.email.data
|
||||||
|
if form.limit_validity.data:
|
||||||
|
session['valid_thru'] = form.valid_thru.data.isoformat()
|
||||||
|
else:
|
||||||
|
session['valid_thru'] = ''
|
||||||
|
session['inactive'] = not form.active.data
|
||||||
|
return redirect(f'/store-token')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/store-token')
|
||||||
|
#@auth_required()
|
||||||
|
def store_token():
|
||||||
|
"""Store token to the token file on disk.
|
||||||
|
|
||||||
|
This will use the token id and the associated data stored in the session cookie (filled by register_token() or
|
||||||
|
edit_token()) and create/modify a token and store the new token file to disk.
|
||||||
|
"""
|
||||||
|
token = session['token']
|
||||||
|
tokens = door.get_tokens()
|
||||||
|
tokens[token] = {'name': session['name'],
|
||||||
|
'email': session['email'],
|
||||||
|
'valid_thru': session['valid_thru'],
|
||||||
|
'inactive': session['inactive'],
|
||||||
|
'organization': 'test_org'}
|
||||||
|
door.store_tokens(tokens)
|
||||||
|
return redirect('/tokens')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/delete-token/<token>')
|
||||||
|
#@auth_required()
|
||||||
|
def delete_token(token):
|
||||||
|
"""Delete the given token from the token file and store the new token file to disk
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
token : str
|
||||||
|
The token to delete from the database.
|
||||||
|
"""
|
||||||
|
tokens = door.get_tokens()
|
||||||
|
if token in tokens: # check if token exists
|
||||||
|
tokens.pop(token)
|
||||||
|
door.store_tokens(tokens)
|
||||||
|
return redirect('/tokens')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/deactivate-token/<token>')
|
||||||
|
#@auth_required()
|
||||||
|
def deactivate_token(token):
|
||||||
|
"""Deactivate access for the given token. This updates the token file on disk.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
token : str
|
||||||
|
The token to deactivate.
|
||||||
|
"""
|
||||||
|
tokens = door.get_tokens()
|
||||||
|
if token in tokens:
|
||||||
|
tokens[token]['inactive'] = True
|
||||||
|
door.store_tokens(tokens)
|
||||||
|
return redirect('/tokens')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run()
|
78
door.py
Normal file
78
door.py
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import regex as re
|
||||||
|
|
||||||
|
|
||||||
|
class Door:
|
||||||
|
def __init__(self, host, port=1883):
|
||||||
|
self.state = None
|
||||||
|
self.encoder_position = None
|
||||||
|
|
||||||
|
self.token_file = 'door_tokens'
|
||||||
|
self.nfc_logfile = 'nfc.log'
|
||||||
|
|
||||||
|
self.mqtt_client = mqtt.Client()
|
||||||
|
self.mqtt_client.on_connect = self.on_connect
|
||||||
|
self.mqtt_client.on_message = self.on_message
|
||||||
|
self.mqtt_client.connect(host=host, port=port)
|
||||||
|
self.mqtt_client.loop_start()
|
||||||
|
|
||||||
|
self.data_fields = ['name', 'organization', 'email', 'valid_thru']
|
||||||
|
|
||||||
|
# The callback for when the client receives a CONNACK response from the server.
|
||||||
|
def on_connect(self, client, userdata, flags, rc):
|
||||||
|
print("Connected with result code " + str(rc))
|
||||||
|
|
||||||
|
# Subscribing in on_connect() means that if we lose the connection and
|
||||||
|
# reconnect then subscriptions will be renewed.
|
||||||
|
client.subscribe("#")
|
||||||
|
|
||||||
|
# The callback for when a PUBLISH message is received from the server.
|
||||||
|
def on_message(self, client, userdata, msg):
|
||||||
|
print(msg.topic + " " + str(msg.payload))
|
||||||
|
if msg.topic == 'door/state/value':
|
||||||
|
self.state = msg.payload.decode()
|
||||||
|
elif msg.topic == 'door/position/value':
|
||||||
|
self.encoder_position = int(msg.payload)
|
||||||
|
|
||||||
|
def get_tokens(self):
|
||||||
|
tokens = {}
|
||||||
|
with open(self.token_file) as f:
|
||||||
|
token_lines = f.readlines()
|
||||||
|
for line in token_lines[1:]:
|
||||||
|
data = line.split('|')
|
||||||
|
inactive = '#' in data[0]
|
||||||
|
token = data[0].strip().strip('#')
|
||||||
|
name = data[1].strip() if len(data) > 1 else None
|
||||||
|
organization = data[2].strip() if len(data) > 2 else None
|
||||||
|
email = data[3].strip() if len(data) > 3 else None
|
||||||
|
valid_thru = data[4].strip() if len(data) > 4 else None
|
||||||
|
|
||||||
|
tokens[token] = {'name': name,
|
||||||
|
'organization': organization,
|
||||||
|
'email': email,
|
||||||
|
'valid_thru': valid_thru,
|
||||||
|
'inactive': inactive}
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def store_tokens(self, tokens):
|
||||||
|
output = '# token | ' + ' | '.join(self.data_fields) + '\n'
|
||||||
|
|
||||||
|
for t, data in tokens.items():
|
||||||
|
output += '#' if data['inactive'] else ''
|
||||||
|
output += t
|
||||||
|
for key in self.data_fields:
|
||||||
|
output += '|'
|
||||||
|
output += data[key] if data[key] else ''
|
||||||
|
output += '\n'
|
||||||
|
with open(self.token_file, 'w') as f:
|
||||||
|
f.write(output)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_most_recent_token(self):
|
||||||
|
# read last invalid token from logfile
|
||||||
|
with open(self.nfc_logfile) as f:
|
||||||
|
nfc_log = f.read()
|
||||||
|
match = re.search(r"(?P<date>\d{4}-\d{2}-\d{2}) (?P<time>\d{2}:\d{2}:\d{2}) Invalid token: (?P<token>[[:xdigit:]]{14})",
|
||||||
|
nfc_log, re.REVERSE)
|
||||||
|
return match
|
7
door_tokens
Normal file
7
door_tokens
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# token | name | organization | email | valid_thru
|
||||||
|
04387cfa186280|David|test_org|d@d.de|
|
||||||
|
#043a81fa186280|Nico|imaginaerraum||
|
||||||
|
04538cfa186280|Simon|test_org|simon.pirkelmann@gmail.com|2021-03-07
|
||||||
|
045e77fa186280|Valentin|test_org|v@v.de|
|
||||||
|
#047378fa186280|Stephan|test_org|s@s.de|2021-03-07
|
||||||
|
#047a76fa186280||||
|
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
flask
|
||||||
|
Flask-Security-Too
|
BIN
static/delete.png
Normal file
BIN
static/delete.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 KiB |
BIN
static/edit.png
Normal file
BIN
static/edit.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.1 KiB |
10881
static/jquery-3.6.0.js
vendored
Normal file
10881
static/jquery-3.6.0.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
static/stop.png
Normal file
BIN
static/stop.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.5 KiB |
46
templates/edit.html
Normal file
46
templates/edit.html
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Token editieren</title>
|
||||||
|
|
||||||
|
<script src="../static/jquery-3.6.0.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<ul class=flashes>
|
||||||
|
{% for message in messages %}
|
||||||
|
<li>{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Token {{ token }} editieren:
|
||||||
|
</p>
|
||||||
|
<form method="POST">
|
||||||
|
<p>{{ form.csrf_token }}
|
||||||
|
<p>{{ form.name.label }} {{ form.name(size=20) }}</p>
|
||||||
|
<p>{{ form.email.label }} {{ form.email(size=20) }}</p>
|
||||||
|
<p>{{ form.limit_validity.label }} {{ form.limit_validity() }}</p>
|
||||||
|
|
||||||
|
<div id="valid_thru_dialog" {% if not form.limit_validity.data %}hidden{% endif %}>
|
||||||
|
<p>{{ form.valid_thru.label }} {{ form.valid_thru() }}</p>
|
||||||
|
</div>
|
||||||
|
<p>{{ form.active.label }} {{ form.active() }}</p>
|
||||||
|
|
||||||
|
<input type="submit" value="Speichern">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$(function () {
|
||||||
|
$("#limit_validity").on("click", function () {
|
||||||
|
$("#valid_thru_dialog").toggle(this.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</html>
|
29
templates/index.html
Normal file
29
templates/index.html
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Space Zugangsverwaltung</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Space Zugangsverwaltung</h1>
|
||||||
|
Zustand der Tür:
|
||||||
|
{% if door_state == 'closed' %}
|
||||||
|
<div style="color: red">
|
||||||
|
Abgeschlossen
|
||||||
|
</div>
|
||||||
|
{% elif door_state == 'open' %}
|
||||||
|
<div style="color: limegreen">
|
||||||
|
Geöffnet
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<br>
|
||||||
|
Position Drehgeber: {{ encoder_position }}
|
||||||
|
<p>
|
||||||
|
<a href="{{ url_for('list_tokens') }}">Übersicht Tokens</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{ url_for('register') }}">Token registrieren</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
51
templates/register.html
Normal file
51
templates/register.html
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Tokens registrieren</title>
|
||||||
|
|
||||||
|
<script src="../static/jquery-3.6.0.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<ul class=flashes>
|
||||||
|
{% for message in messages %}
|
||||||
|
<li>{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% if token is not none %}
|
||||||
|
Letzter unregistrierter Token: {{ token['token'] }} <br>
|
||||||
|
Gelesen am {{ token['date'] }} um {{ token['time'] }}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
RaumnutzerIn registrieren:
|
||||||
|
</p>
|
||||||
|
<form method="POST">
|
||||||
|
<p>{{ form.csrf_token }}
|
||||||
|
<p>{{ form.name.label }} {{ form.name(size=20) }}</p>
|
||||||
|
<p>{{ form.email.label }} {{ form.email(size=20) }}</p>
|
||||||
|
<p>{{ form.limit_validity.label }} {{ form.limit_validity() }}</p>
|
||||||
|
<div id="valid_thru_dialog" {% if not form.limit_validity.data %}hidden{% endif %}>
|
||||||
|
<p>{{ form.valid_thru.label }} {{ form.valid_thru() }}</p>
|
||||||
|
</div>
|
||||||
|
<p>{{ form.dsgvo.label }} {{ form.dsgvo() }}</p>
|
||||||
|
<input type="submit" value="Abschicken">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
Keine unregistrierten Tokens in Logfile gefunden. Bitte Token scannen und die Seite neu laden.
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
$(function () {
|
||||||
|
$("#limit_validity").on("click", function () {
|
||||||
|
$("#valid_thru_dialog").toggle(this.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</html>
|
52
templates/tokens.html
Normal file
52
templates/tokens.html
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Tokens</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% with messages = get_flashed_messages() %}
|
||||||
|
{% if messages %}
|
||||||
|
<ul class=flashes>
|
||||||
|
{% for message in messages %}
|
||||||
|
<li>{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
<table border="1">
|
||||||
|
<td>Token</td>
|
||||||
|
<td>NutzerIn</td>
|
||||||
|
<td>Organisation</td>
|
||||||
|
<td>E-Mail</td>
|
||||||
|
<td>Gültig bis</td>
|
||||||
|
<td>Aktionen</td>
|
||||||
|
{% for t, data in assigned_tokens.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ t }}</td>
|
||||||
|
{% for field in ['name', 'organization', 'email', 'valid_thru'] %}
|
||||||
|
<td>{{ data[field] if data[field] }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('edit_token', token=t) }}"><img src="static/edit.png" title="Editieren" alt="Edit"></a>
|
||||||
|
<a href="{{ url_for('deactivate_token', token=t) }}"><img src="static/stop.png" title="Deaktivieren" alt="Deactivate"></a>
|
||||||
|
<a href="{{ url_for('delete_token', token=t) }}"><img src="static/delete.png" title="Löschen" alt="Delete"></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% for t, data in inactive_tokens.items() %}
|
||||||
|
<tr style="background-color: lightgrey">
|
||||||
|
<td>{{ t }}</td>
|
||||||
|
{% for field in ['name', 'organization', 'email', 'valid_thru'] %}
|
||||||
|
<td>{{ data[field] if data[field] }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('edit_token', token=t) }}"><img src="static/edit.png" title="Editieren" alt="Edit"></a>
|
||||||
|
<a href="{{ url_for('delete_token', token=t) }}"><img src="static/delete.png" title="Löschen" alt="Delete"></a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in New Issue
Block a user