diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 37e09c5d80234cbb5edacbab5bc92d550772a692..1989e049c4c3c37f7c9ae3f892294716633f36e5 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -5,7 +5,7 @@
 * Install python3 and virtualenv
 * Create a virtualenv and install the requirements
 
-```
+```sh
 virtualenv -p python3 venv
 source venv/bin/activate
 pip install -r requirements.txt
@@ -14,7 +14,7 @@ pip install -r requirements.txt
 * Create a database (with sqlite, postgres, …)
 * Create a config file
 
-```
+```sh
 cp config.py.example config.py
 ```
 
@@ -25,17 +25,49 @@ cp config.py.example config.py
 
 * Fill your database
 
-```
+```sh
 ./server.py db upgrade
 ```
 
 ## Running the program
 
-```
+Run (in two terminals, one for the server and one for celery):
+
+```sh
 source venv/bin/activate
 ./server.py runserver
 ```
+```sh
+source venv/bin/activate
+./start_celery.sh
+```
 
 The website will run on `localhost:5000`.
 
+## Data model
+
+The data model is defined in `models/database.py`.
+Each type should inherit from `DatabaseModel` and have a method `get_parent()`, which is responsible for the right management and some magic.
+
+## Server endpoints
+
+The actual websites are defined in `server.py`. They begin with the decorator `@app.route((route)` and return a string, usually through `render_template(template, **parameters)`.
+There can be more decorators inbetween `app.route` and the function.
+A simple website might look like this:
+```python
+@app.route("/documentation")
+@login_required
+def documentation():
+    todostates = list(TodoState)
+    name_to_state = TodoState.get_name_to_state()
+    return render_template("documentation.html", todostates=todostates, name_to_state=name_to_state)
+```
+
+### Decorators
 
+* `app.route(route)`: Defines for which URLs this function will be called.
+  - The route may contain variables: `"/type/edit/<int:protocoltype:id>"`. These will be passed to the function.
+  - Additionally, allowed HTTP methods may be defined: `@app.route("/type/new", methods=["GET", "POST"])`. The default is `GET`, but endpoints for forms require `POST` as well.
+* `login_required`: Anonymous users will be redirected to the login page.
+* `group_required(group)`: Users without this group will see an error message.
+* `db_lookup(DataModel)`: Looks up an element of this type. The route needs to have an argument "{model_name}_id". 
diff --git a/auth.py b/auth.py
index 58d16a52ca1e9d2b7117d20fc312fb03f7d621de..0323a2fd3d1253fa22e80baeae21ff634cac0eb0 100644
--- a/auth.py
+++ b/auth.py
@@ -3,6 +3,7 @@ import ssl
 import ldap3
 from ldap3.utils.dn import parse_dn
 from datetime import datetime
+import grp, pwd, pam
 
 class User:
     def __init__(self, username, groups, timestamp=None, obsolete=False, permanent=False):
@@ -42,7 +43,7 @@ class UserManager:
     def login(self, username, password, permanent=False):
         for backend in self.backends:
             if backend.authenticate(username, password):
-                groups = backend.groups(username, password)
+                groups = sorted(list(set(backend.groups(username, password))))
                 return User(username, groups, obsolete=backend.obsolete, permanent=permanent)
         return None
 
@@ -135,6 +136,50 @@ class ADManager:
         for result in reader.search():
             yield result.name.value
 
+
+class StaticUserManager:
+    def __init__(self, users, obsolete=False):
+        self.passwords = {
+            username: password
+            for (username, password, groups) in users
+        }
+        self.group_map = {
+            username: groups
+            for (username, password, groups) in users
+        }
+        self.obsolete = obsolete
+
+    def authenticate(self, username, password):
+        return (username in self.passwords
+            and self.passwords[username] == password)
+
+    def groups(self, username, password=None):
+        if username in self.group_map:
+            yield from self.group_map[username]
+
+    def all_groups(self):
+        yield from list(set(group for group in groups.values()))
+
+
+class PAMManager:
+    def __init__(self, obsolete=False):
+        self.pam = pam.pam()
+        self.obsolete = obsolete
+
+    def authenticate(self, username, password):
+        return self.pam.authenticate(username, password)
+
+    def groups(self, username, password=None):
+        print(username)
+        yield grp.getgrgid(pwd.getpwnam(username).pw_gid).gr_name
+        for group in grp.getgrall():
+            if username in group.gr_mem:
+                yield group.gr_name
+
+    def all_groups(self):
+        for group in grp.getgrall():
+            yield group.gr_name
+
 class SecurityManager:
     def __init__(self, key, max_duration=300):
         self.maccer = hmac.new(key.encode("utf-8"), digestmod=hashlib.sha512)
diff --git a/config.py.example b/config.py.example
index 181aed80df8292e0b7ca85efbeaed493548475d2..ee7824736c7246d08ac2f3248fa6fbbe917d3c30 100644
--- a/config.py.example
+++ b/config.py.example
@@ -26,10 +26,10 @@ CELERY_ACCEPT_CONTENT = ["pickle"] # do not change
 PRINTING_ACTIVE = True
 PRINTING_SERVER = "printsrv.example.com:631"
 PRINTING_USER = "protocols"
-PRINTING_PRINTERS = [
+PRINTING_PRINTERS = {
     "example_printer": ["Duplex=DuplexNoTumble", "option2=value"],
     "other_printer": ["list", "of", "options"]
-]
+}
 
 # etherpad (optional)
 ETHERPAD_ACTIVE = True
@@ -62,6 +62,7 @@ SESSION_PROTECTION = "strong" # do not change
 # authentication
 SECURITY_KEY = "some other random string" # change this
 AUTH_MAX_DURATION = 300
+from auth import LdapManager, ADManager, StaticUserManager
 AUTH_BACKENDS = [
     LdapManager(
         host="ldap.example.com",
@@ -72,7 +73,13 @@ AUTH_BACKENDS = [
         domain="EXAMPLE",
         user_dn="cn=users,dc=example,dc=com",
         group_dn="dc=example,dc=com",
-        ca_cert="/etc/ssl/certs/example-ca.pem")
+        ca_cert="/etc/ssl/certs/example-ca.pem"),
+    StaticUserManager(
+        users=(
+            ("username", "password", ("group1", "group2")),
+            ("testuser", "abc123", ("group1")),
+        )
+    )
 ]
 
 OBSOLETION_WARNING = """Please migrate your account!""" # not important
diff --git a/models/database.py b/models/database.py
index 737086e861193920dbb4e9c6c52482549df83f8c..a559fd02e3302651ab9f296ed3bf2ee4d4fed64c 100644
--- a/models/database.py
+++ b/models/database.py
@@ -701,6 +701,8 @@ class Error(DatabaseModel):
         return self.protocol
 
     def get_short_description(self):
+        if not self.description:
+            return ""
         lines = self.description.splitlines()
         if len(lines) <= 4:
             return "\n".join(lines)
diff --git a/requirements.txt b/requirements.txt
index 0451180e4d287f15e0d721b6187783ea862f0fa4..a2f2947eb39ae44aeb79b476503995bde6c73081 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -32,7 +32,7 @@ MarkupSafe==0.23
 nose==1.3.7
 packaging==16.8
 pathtools==0.1.2
-psycopg2==2.6.2
+psycopg2==2.7.4
 pyasn1==0.2.3
 Pygments==2.2.0
 pyldap==2.4.28
@@ -41,6 +41,7 @@ python-dateutil==2.6.0
 python-editor==1.0.3
 python-engineio==1.2.2
 python-Levenshtein==0.12.0
+python-pam==1.8.2
 python-socketio==1.7.1
 pytz==2016.10
 PyYAML==3.12
diff --git a/tasks.py b/tasks.py
index 6dc8366529c90ebe8582bea84a9017ef6b010601..b5c4818af2e6dcfc72cb4b93b0fa3a5b4e5c0e1f 100644
--- a/tasks.py
+++ b/tasks.py
@@ -637,9 +637,9 @@ def print_file_async(filename, protocol_id):
             for option in config.PRINTING_PRINTERS[protocol.protocoltype.printer]:
                 command.extend(["-o", '"{}"'.format(option) if " " in option else option])
             command.append(filename)
-            subprocess.check_call(command, universal_newlines=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
-        except subprocess.SubprocessError:
-            error = protocol.create_error("Printing", "Printing {} failed.".format(protocol.get_identifier()), "")
+            subprocess.check_output(command, universal_newlines=True, stderr=subprocess.STDOUT)
+        except subprocess.SubprocessError as exception:
+            error = protocol.create_error("Printing", "Printing {} failed.".format(protocol.get_identifier()), exception.stdout)
             db.session.add(error)
             db.session.commit()
 
diff --git a/templates/protocol-template.txt b/templates/protocol-template.txt
index 57e2c13f8283d1b413d0cdec928e84f24e4908ba..ff2efce96b41e8241c7d48584769a8473535b933 100644
--- a/templates/protocol-template.txt
+++ b/templates/protocol-template.txt
@@ -1,8 +1,13 @@
 #Datum;{{protocol.date|datify_short}}
 #Beginn;{{protocol.protocoltype.usual_time|timify_short}}
 #Ende;
+{% for meta in protocol.metas %}
+#{{meta.name}};{{meta.value}}
+{% endfor %}
 {% for defaultmeta in protocol.protocoltype.metas %}
+    {% if not defaultmeta.prior %}
 #{{defaultmeta.key}};{{defaultmeta.value}}
+    {% endif %}
 {% endfor %}
 
 {% macro render_top(top, use_description=False) %}