diff --git a/aleksis/apps/kort/api.py b/aleksis/apps/kort/api.py
new file mode 100644
index 0000000000000000000000000000000000000000..5179acf9a46240b18110decf532ea94eeee95ab5
--- /dev/null
+++ b/aleksis/apps/kort/api.py
@@ -0,0 +1,182 @@
+from django.contrib import admin
+from django.shortcuts import get_object_or_404
+from django.utils import timezone
+
+from celery.result import allow_join_result
+from celery.states import SUCCESS
+from oauth2_provider.contrib.rest_framework import TokenHasScope
+from rest_framework import generics, permissions, serializers
+from rest_framework.exceptions import APIException, ValidationError
+from rest_framework.permissions import BasePermission
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from aleksis.apps.kort.models import Card, CardPrinter, CardPrintJob
+
+admin.autodiscover()
+
+
+class CorrectPrinterPermission(BasePermission):
+    """Check whether the OAuth2 application belongs to the printer."""
+
+    def has_object_permission(self, request, view, obj) -> bool:
+        token = request.auth
+        if token.application == obj.oauth2_application:
+            return True
+        return False
+
+
+class CorrectJobPrinterPermission(BasePermission):
+    """Check whether the OAuth2 application belongs to the job's printer."""
+
+    def has_object_permission(self, request, view, obj) -> bool:
+        token = request.auth
+        if token.application == obj.printer.oauth2_application:
+            return True
+        return False
+
+
+class CardPrinterSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = CardPrinter
+        fields = (
+            "id",
+            "name",
+            "description",
+            "location",
+            "status",
+            "status_label",
+            "status_color",
+            "status_icon",
+            "status_text",
+            "last_seen_at",
+            "cups_printer",
+            "generate_number_on_server",
+            "card_detector",
+        )
+
+
+class CardPrinterStatusSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = CardPrinter
+        fields = ("status", "status_text")
+
+
+class CardSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Card
+        fields = ("id", "chip_number", "valid_until", "deactivated", "person", "pdf_file")
+
+
+class CardPrintJobSerializer(serializers.ModelSerializer):
+    card = CardSerializer()
+
+    class Meta:
+        model = CardPrintJob
+        fields = ("id", "printer", "card", "status", "status_text")
+
+
+class CardPrintJobStatusSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = CardPrintJob
+        fields = ("id", "status", "status_text")
+
+
+class CardChipNumberSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Card
+        fields = ("chip_number",)
+
+
+class CardPrinterDetails(generics.RetrieveAPIView):
+    """Show details about the card printer."""
+
+    permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectPrinterPermission]
+    required_scopes = ["card_printer"]
+    serializer_class = CardPrinterSerializer
+    queryset = CardPrinter.objects.all()
+
+    def get_object(self):
+        token = self.request.auth
+        return token.application.card_printers.all().first()
+
+
+class CardPrinterUpdateStatus(generics.UpdateAPIView):
+    """Update the status of the card printer."""
+
+    permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectPrinterPermission]
+    required_scopes = ["card_printer"]
+    serializer_class = CardPrinterStatusSerializer
+    queryset = CardPrinter.objects.all()
+
+    def update(self, request, *args, **kwargs):
+        r = super().update(request, *args, **kwargs)
+        instance = self.get_object()
+        instance.last_seen_at = timezone.now()
+        instance.save()
+        return r
+
+
+class GetNextPrintJob(APIView):
+    """Get the next print job."""
+
+    permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectPrinterPermission]
+    required_scopes = ["card_printer"]
+    serializer_class = CardPrinterSerializer
+    queryset = CardPrinter.objects.all()
+
+    def get_object(self, pk):
+        return get_object_or_404(CardPrinter, pk=pk)
+
+    def get(self, request, pk, *args, **kwargs):
+        printer = self.get_object(pk)
+        job = printer.get_next_print_job()
+        if not job:
+            return Response({"status": "no_job"})
+        serializer = CardPrintJobSerializer(job)
+        return Response(serializer.data)
+
+
+class CardPrintJobUpdateStatusView(generics.UpdateAPIView):
+    """Update the status of the card printer."""
+
+    permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectJobPrinterPermission]
+    required_scopes = ["card_printer"]
+    serializer_class = CardPrintJobStatusSerializer
+    queryset = CardPrintJob.objects.all()
+
+
+class CardPrintJobSetChipNumberView(generics.UpdateAPIView):
+    """Update the status of the card printer."""
+
+    permission_classes = [permissions.IsAuthenticated, TokenHasScope, CorrectJobPrinterPermission]
+    required_scopes = ["card_printer"]
+    serializer_class = CardChipNumberSerializer
+    queryset = CardPrintJob.objects.all()
+
+    def update(self, request, *args, **kwargs):
+        instance = self.get_object()
+        card = instance.card
+
+        if card.chip_number:
+            raise ValidationError
+
+        serializer = self.get_serializer(card, data=request.data)
+        serializer.is_valid(raise_exception=True)
+        self.perform_update(serializer)
+
+        result = instance.card.generate_pdf()
+
+        with allow_join_result():
+            result.wait()
+            card.refresh_from_db()
+
+        if result.status == SUCCESS and card.pdf_file:
+            serializer = CardPrintJobSerializer(instance)
+            instance.refresh_from_db()
+
+            return Response(serializer.data)
+        else:
+            card.chip_number = None
+            card.save()
+            raise APIException("Error while generating PDF file")
diff --git a/aleksis/apps/kort/apps.py b/aleksis/apps/kort/apps.py
index 797ede1bc9d1a9db4456910879452dccfc6bdf85..437a5bb8e682058629a89254713ccd3fba0a1468 100644
--- a/aleksis/apps/kort/apps.py
+++ b/aleksis/apps/kort/apps.py
@@ -1,6 +1,3 @@
-from django.apps import apps
-from django.db import models
-from django.db.models import functions
 from django.utils.translation import gettext as _
 
 from aleksis.core.util.apps import AppConfig
@@ -25,18 +22,4 @@ class DefaultConfig(AppConfig):
     @classmethod
     def get_all_scopes(cls) -> dict[str, str]:
         """Return all OAuth scopes and their descriptions for this app."""
-        Card = apps.get_model("kort", "Card")
-        label_prefix = _("Access and manage printer status and print jobs")
-        scopes = dict(
-            Card.objects.annotate(
-                scope=functions.Concat(
-                    models.Value(f"{Card.SCOPE_PREFIX}_"),
-                    models.F("pk"),
-                    output_field=models.CharField(),
-                ),
-                label=functions.Concat(models.Value(f"{label_prefix}: "), models.F("name")),
-            )
-            .values_list("scope", "label")
-            .distinct()
-        )
-        return scopes
+        return {"card_printer": _("Access and manage printer status and print jobs")}
diff --git a/aleksis/apps/kort/forms.py b/aleksis/apps/kort/forms.py
index 6036332b97911d095c50df5b7fec2078f5f5435f..6b947e78e04f8173a1b7d372746b9cfac73a4d63 100644
--- a/aleksis/apps/kort/forms.py
+++ b/aleksis/apps/kort/forms.py
@@ -1,8 +1,10 @@
 from django import forms
+from django.utils.translation import gettext as _
 
 from django_select2.forms import ModelSelect2Widget
+from material import Fieldset, Layout
 
-from aleksis.apps.kort.models import Card
+from aleksis.apps.kort.models import Card, CardPrinter
 
 
 class CardForm(forms.ModelForm):
@@ -19,3 +21,35 @@ class CardForm(forms.ModelForm):
                 attrs={"data-minimum-input-length": 0, "class": "browser-default"},
             ),
         }
+
+
+class CardPrinterForm(forms.ModelForm):
+    layout = Layout(
+        Fieldset(_("Generic attributes"), "name", "location", "description"),
+        Fieldset(
+            _("Printer settings"), "cups_printer", "generate_number_on_server", "card_detector"
+        ),
+    )
+
+    class Meta:
+        model = CardPrinter
+        fields = [
+            "name",
+            "location",
+            "description",
+            "cups_printer",
+            "generate_number_on_server",
+            "card_detector",
+        ]
+
+
+class PrinterSelectForm(forms.Form):
+    layout = Layout("printer")
+    printer = forms.ModelChoiceField(queryset=None, label=_("Card Printer"), required=True)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        printers = CardPrinter.objects.all()
+        self.fields["printer"].queryset = printers
+        if printers.count() == 1:
+            self.fields["printer"].initial = printers.first()
diff --git a/aleksis/apps/kort/menus.py b/aleksis/apps/kort/menus.py
index 812fb3894999177feb8f04c7b2644c635756e5e1..2116a83244bdcf51f4870a6b21c3c193d4cd8fb6 100644
--- a/aleksis/apps/kort/menus.py
+++ b/aleksis/apps/kort/menus.py
@@ -23,6 +23,17 @@ MENUS = {
                         )
                     ],
                 },
+                {
+                    "name": _("Card Printers"),
+                    "url": "card_printers",
+                    "svg_icon": "mdi:printer-outline",
+                    "validators": [
+                        (
+                            "aleksis.core.util.predicates.permission_validator",
+                            "core.view_cardprinters_rule",
+                        )
+                    ],
+                },
             ],
         }
     ]
diff --git a/aleksis/apps/kort/migrations/0007_auto_20220315_1957.py b/aleksis/apps/kort/migrations/0007_auto_20220315_1957.py
new file mode 100644
index 0000000000000000000000000000000000000000..5a5cef67ea2067c06c8d555f5c0f60996ec8c6e9
--- /dev/null
+++ b/aleksis/apps/kort/migrations/0007_auto_20220315_1957.py
@@ -0,0 +1,49 @@
+# Generated by Django 3.2.12 on 2022-03-15 18:57
+
+from django.conf import settings
+import django.contrib.sites.managers
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
+        ('sites', '0002_alter_domain_unique'),
+        ('kort', '0006_auto_20220310_2003'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='card',
+            name='pdf_file',
+            field=models.FileField(blank=True, default='', upload_to='cards/', validators=[django.core.validators.FileExtensionValidator(['pdf'])], verbose_name='PDF file'),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='cardprinter',
+            name='oauth2_application',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='card_printers', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, verbose_name='OAuth2 application'),
+        ),
+        migrations.CreateModel(
+            name='CardPrintJob',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('extended_data', models.JSONField(default=dict, editable=False)),
+                ('status', models.CharField(choices=[('registered', 'Registered'), ('in_progress', 'In progress'), ('finished', 'Finished')], default='registered', max_length=255, verbose_name='Status')),
+                ('status_text', models.TextField(blank=True, verbose_name='Status text')),
+                ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kort.card', verbose_name='Card')),
+                ('printer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='kort.cardprinter', verbose_name='Printer')),
+                ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
+            ],
+            options={
+                'verbose_name': 'Card print job',
+                'verbose_name_plural': 'Card print jobs',
+            },
+            managers=[
+                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+            ],
+        ),
+    ]
diff --git a/aleksis/apps/kort/migrations/0008_auto_20220319_2018.py b/aleksis/apps/kort/migrations/0008_auto_20220319_2018.py
new file mode 100644
index 0000000000000000000000000000000000000000..d350d51ecefc8d0a89df3ae9516d355b94f8c38d
--- /dev/null
+++ b/aleksis/apps/kort/migrations/0008_auto_20220319_2018.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.12 on 2022-03-19 19:18
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('kort', '0007_auto_20220315_1957'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='cardprinter',
+            name='cups_printer',
+            field=models.CharField(blank=True, max_length=255, verbose_name='CUPS printer'),
+        ),
+        migrations.AlterField(
+            model_name='cardprintjob',
+            name='printer',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='kort.cardprinter', verbose_name='Printer'),
+        ),
+        migrations.AlterField(
+            model_name='cardprintjob',
+            name='status',
+            field=models.CharField(choices=[('registered', 'Registered'), ('in_progress', 'In progress'), ('finished', 'Finished'), ('failed', 'Failed')], default='registered', max_length=255, verbose_name='Status'),
+        ),
+    ]
diff --git a/aleksis/apps/kort/migrations/0009_cardprinter_generate_number_on_server.py b/aleksis/apps/kort/migrations/0009_cardprinter_generate_number_on_server.py
new file mode 100644
index 0000000000000000000000000000000000000000..e099be6a0247fac83180714d00c8d57424ede663
--- /dev/null
+++ b/aleksis/apps/kort/migrations/0009_cardprinter_generate_number_on_server.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.12 on 2022-03-26 16:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('kort', '0008_auto_20220319_2018'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='cardprinter',
+            name='generate_number_on_server',
+            field=models.BooleanField(default=True, verbose_name='Generate card number on server'),
+        ),
+    ]
diff --git a/aleksis/apps/kort/migrations/0010_auto_20220326_2123.py b/aleksis/apps/kort/migrations/0010_auto_20220326_2123.py
new file mode 100644
index 0000000000000000000000000000000000000000..028d25cd9ed0f4cf1d6ea27e3dfb541e53054755
--- /dev/null
+++ b/aleksis/apps/kort/migrations/0010_auto_20220326_2123.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.12 on 2022-03-26 20:23
+
+from django.db import migrations
+import django.utils.timezone
+import model_utils.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('kort', '0009_cardprinter_generate_number_on_server'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='cardprintjob',
+            name='created',
+            field=model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created'),
+        ),
+        migrations.AddField(
+            model_name='cardprintjob',
+            name='modified',
+            field=model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified'),
+        ),
+    ]
diff --git a/aleksis/apps/kort/migrations/0011_cardprinter_card_detector.py b/aleksis/apps/kort/migrations/0011_cardprinter_card_detector.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a62022f08deff9b7d61b4d88d70c529486607b6
--- /dev/null
+++ b/aleksis/apps/kort/migrations/0011_cardprinter_card_detector.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.13 on 2022-04-11 14:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('kort', '0010_auto_20220326_2123'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='cardprinter',
+            name='card_detector',
+            field=models.CharField(blank=True, max_length=255, verbose_name='Card detector'),
+        ),
+    ]
diff --git a/aleksis/apps/kort/models.py b/aleksis/apps/kort/models.py
index 11e2e03d521202339c1c5f155b6a05d3b97ee60d..eac8c3bf0211296c6f6d34704ace0db9eb3bb505 100644
--- a/aleksis/apps/kort/models.py
+++ b/aleksis/apps/kort/models.py
@@ -1,13 +1,18 @@
-from typing import Union
+import uuid
+from datetime import timedelta
+from typing import Any, Optional, Union
 
+from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.core.validators import FileExtensionValidator
 from django.db import models
+from django.db.models import Q
 from django.template import Context, Template
 from django.utils import timezone
 from django.utils.translation import gettext as _
 
 from celery.result import AsyncResult
+from model_utils.models import TimeStampedModel
 
 from aleksis.core.mixins import ExtensibleModel
 from aleksis.core.models import OAuthApplication, Person
@@ -19,11 +24,37 @@ class CardPrinterStatus(models.TextChoices):
     WITH_ERRORS = "with_errors", _("With errors")
     NOT_REGISTERED = "not_registered", _("Not registered")
 
+    @classmethod
+    def get_color(cls, value):
+        _colors = {
+            CardPrinterStatus.ONLINE.value: "green",
+            CardPrinterStatus.OFFLINE.value: "red",
+            CardPrinterStatus.WITH_ERRORS.value: "orange",
+            CardPrinterStatus.NOT_REGISTERED.value: "grey",
+        }
+        return _colors.get(value)
+
+    @classmethod
+    def get_icon(cls, value):
+        _icons = {
+            CardPrinterStatus.ONLINE.value: "mdi:printer-check",
+            CardPrinterStatus.OFFLINE.value: "mdi:printer-off",
+            CardPrinterStatus.WITH_ERRORS.value: "mdi:printer-alert",
+            CardPrinterStatus.NOT_REGISTERED.value: "mdi:printer-search",
+        }
+        return _icons.get(value)
+
+    @classmethod
+    def get_label(cls, value):
+        _labels = {x: y for x, y in cls.choices}
+        return _labels.get(value)
+
 
 class PrintStatus(models.TextChoices):
     REGISTERED = "registered", _("Registered")
     IN_PROGRESS = "in_progress", _("In progress")
     FINISHED = "finished", _("Finished")
+    FAILED = "failed", _("Failed")
 
 
 class CardPrinter(ExtensibleModel):
@@ -47,14 +78,23 @@ class CardPrinter(ExtensibleModel):
         verbose_name=_("OAuth2 application"),
         blank=True,
         null=True,
+        related_name="card_printers",
     )
 
+    # Settings
+    cups_printer = models.CharField(max_length=255, verbose_name=_("CUPS printer"), blank=True)
+    generate_number_on_server = models.BooleanField(
+        default=True, verbose_name=_("Generate card number on server")
+    )
+    card_detector = models.CharField(max_length=255, verbose_name=_("Card detector"), blank=True)
+
     def save(self, *args, **kwargs):
         if not self.oauth2_application:
             application = OAuthApplication(
                 client_type=OAuthApplication.CLIENT_CONFIDENTIAL,
                 authorization_grant_type=OAuthApplication.GRANT_AUTHORIZATION_CODE,
                 name=f"Card printer: {self.name}",
+                redirect_uris="urn:ietf:wg:oauth:2.0:oob",
             )
             application.save()
             self.oauth2_application = application
@@ -64,6 +104,73 @@ class CardPrinter(ExtensibleModel):
     def __str__(self):
         return self.name
 
+    @property
+    def status_label(self) -> str:
+        """Return the verbose name of the status."""
+        return CardPrinterStatus.get_label(self.status)
+
+    @property
+    def status_color(self) -> str:
+        """Return a color for the status."""
+        return CardPrinterStatus.get_color(self.status)
+
+    @property
+    def status_icon(self) -> str:
+        """Return an iconify icon for the status."""
+        return CardPrinterStatus.get_icon(self.status)
+
+    def generate_config(self) -> dict[str, Any]:
+        """Generate the configuration for the printer client."""
+        config = {
+            "base_url": settings.BASE_URL,
+            "client_id": self.oauth2_application.client_id,
+            "client_secret": self.oauth2_application.client_secret,
+        }
+        return config
+
+    @property
+    def config_filename(self) -> str:
+        """Return the filename for the printer client configuration."""
+        return f"card-printer-config-{self.pk}.json"
+
+    def check_online_status(self):
+        if (
+            self.status
+            not in (CardPrinterStatus.NOT_REGISTERED.value, CardPrinterStatus.OFFLINE.value)
+            and self.last_seen_at
+        ):
+            if self.last_seen_at < timezone.now() - timedelta(minutes=1):
+                self.status = CardPrinterStatus.OFFLINE.value
+                self.save()
+
+    @classmethod
+    def check_online_status_for_all(cls):
+        for printer in cls.objects.all():
+            printer.check_online_status()
+
+    def get_next_print_job(self) -> Optional["CardPrintJob"]:
+        if not self.generate_number_on_server:
+            print(
+                self.jobs.filter(card__pdf_file=""),
+                self.jobs.filter(card__pdf_file="").exclude(card__chip_number=""),
+            )
+            self.jobs.filter(
+                (Q(card__pdf_file="") & ~Q(card__chip_number=""))
+                | Q(status=PrintStatus.IN_PROGRESS)
+            ).update(status=PrintStatus.FAILED)
+            Card.objects.filter(
+                jobs__in=self.jobs.filter(status=PrintStatus.FAILED), chip_number=""
+            ).update(chip_number="")
+        else:
+            self.jobs.filter(status=PrintStatus.IN_PROGRESS).update(status=PrintStatus.FAILED)
+
+        jobs = self.jobs.order_by("created").filter(status=PrintStatus.REGISTERED)
+        if self.generate_number_on_server:
+            jobs = jobs.filter(card__pdf_file__isnull=False)
+        if jobs.exists():
+            return jobs.first()
+        return None
+
     class Meta:
         verbose_name = _("Card printer")
         verbose_name_plural = _("Card printers")
@@ -130,6 +237,19 @@ class Card(ExtensibleModel):
             return True
         return generate_card_pdf.delay(self.pk)
 
+    def print_card(self, printer: CardPrinter):
+        job = CardPrintJob(card=self, printer=printer)
+        job.save()
+        if not self.chip_number and printer.generate_number_on_server:
+            self.chip_number = str(self.generate_number())
+            self.save()
+        if self.chip_number:
+            self.generate_pdf()
+        return job
+
+    def generate_number(self) -> int:
+        return uuid.uuid1().int >> 32
+
     def __str__(self):
         if self.chip_number:
             return f"{self.person} ({self.chip_number})"
@@ -138,3 +258,24 @@ class Card(ExtensibleModel):
     class Meta:
         verbose_name = _("Card")
         verbose_name_plural = _("Cards")
+
+
+class CardPrintJob(TimeStampedModel, ExtensibleModel):
+    printer = models.ForeignKey(
+        CardPrinter, on_delete=models.CASCADE, verbose_name=_("Printer"), related_name="jobs"
+    )
+    card = models.ForeignKey(
+        Card, on_delete=models.CASCADE, verbose_name=_("Card"), related_name="jobs"
+    )
+
+    status = models.CharField(
+        max_length=255,
+        verbose_name=_("Status"),
+        choices=PrintStatus.choices,
+        default=PrintStatus.REGISTERED,
+    )
+    status_text = models.TextField(verbose_name=_("Status text"), blank=True)
+
+    class Meta:
+        verbose_name = _("Card print job")
+        verbose_name_plural = _("Card print jobs")
diff --git a/aleksis/apps/kort/tables.py b/aleksis/apps/kort/tables.py
index 5b53edde95c511f73605f25b3adc094ee3b674e6..dedcab6e0dcc93480212de4db94f975ae5344ef2 100644
--- a/aleksis/apps/kort/tables.py
+++ b/aleksis/apps/kort/tables.py
@@ -1,7 +1,17 @@
 from django.template.loader import render_to_string
 from django.utils.translation import gettext as _
 
-from django_tables2 import A, BooleanColumn, Column, LinkColumn, RelatedLinkColumn, Table
+from django_tables2 import (
+    A,
+    BooleanColumn,
+    Column,
+    DateTimeColumn,
+    LinkColumn,
+    RelatedLinkColumn,
+    Table,
+)
+
+from aleksis.apps.kort.forms import PrinterSelectForm
 
 
 class CardTable(Table):
@@ -26,4 +36,33 @@ class CardTable(Table):
         )
 
     def render_actions(self, value, record):
-        return render_to_string("kort/card/actions.html", dict(pk=value, card=record))
+        return render_to_string(
+            "kort/card/actions.html", dict(pk=value, card=record, printer_form=PrinterSelectForm())
+        )
+
+
+class CardPrinterTable(Table):
+    """Table to list card printers."""
+
+    class Meta:
+        attrs = {"class": "highlight"}
+
+    name = LinkColumn("card_printer", verbose_name=_("Printer name"), args=[A("pk")])
+    location = Column(verbose_name=_("Printer location"))
+
+    current_status = Column(verbose_name=_("Current status"), accessor=A("pk"))
+    last_seen_at = DateTimeColumn(verbose_name=_("Last seen at"))
+    jobs_count = Column(verbose_name=_("Running jobs"))
+
+    actions = Column(verbose_name=_("Actions"), accessor=A("pk"))
+
+    def render_current_status(self, value, record):
+        return render_to_string(
+            "kort/printer/status.html",
+            dict(
+                printer=record,
+            ),
+        )
+
+    def render_actions(self, value, record):
+        return render_to_string("kort/printer/actions.html", dict(pk=value, printer=record))
diff --git a/aleksis/apps/kort/templates/kort/card/detail_content.html b/aleksis/apps/kort/templates/kort/card/detail_content.html
index 78f6036b10bc65ac2cba248a1e7cd78d687de970..23b62275f9a177c6e2e0e03230efbdc620aed838 100644
--- a/aleksis/apps/kort/templates/kort/card/detail_content.html
+++ b/aleksis/apps/kort/templates/kort/card/detail_content.html
@@ -39,9 +39,12 @@
     </div>
   </div>
   <div class="col s12 m12 l6">
+    {% include "kort/card/print_form.html" %}
     {% if card.pdf_file %}
-      <div id="card-pdf-{{ card.pk }}" style="height: 500px;"></div>
-      <script>PDFObject.embed("{{ card.pdf_file.url }}", "#card-pdf-{{ card.pk }}");</script>
+      <a href="{{ card.pdf_file.url }}" class="btn waves-effect waves-light">
+        <i class="material-icons left iconify" data-icon="mdi:file-pdf-box"></i>
+        {% trans "Show card as PDF" %}
+      </a>
     {% else %}
       <div class="row center-via-flex">
         <a class="btn waves-effect waves-light" href="{% url "generate_card_pdf" card.pk %}">
diff --git a/aleksis/apps/kort/templates/kort/card/print_form.html b/aleksis/apps/kort/templates/kort/card/print_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..01fef6935f959dcfc523c5c283bd509fdf362b26
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/card/print_form.html
@@ -0,0 +1,18 @@
+{% load material_form i18n %}
+<form action="{% url "print_card" card.pk %}" method="get">
+  <div class="card">
+    <div class="card-content">
+      <div class="card-title"><i class="material-icons left iconify"
+                                 data-icon="mdi:printer-outline"></i> {% trans "Print card" %}</div>
+
+      {% csrf_token %}
+      {% form form=printer_form %}{% endform %}
+    </div>
+    <div class="card-action-light">
+      <button type="submit" class="btn waves-effect waves-light">
+        <i class="material-icons left iconify" data-icon="mdi:printer-outline"></i>
+        {% trans "Print card" %}
+      </button>
+    </div>
+  </div>
+</form>
diff --git a/aleksis/apps/kort/templates/kort/card/status.html b/aleksis/apps/kort/templates/kort/card/status.html
index 009baa0bcdea5fbd575d481ae9f2a0efcf9bd7af..dd5fa55c89ba3d28d420b1d7de7444891c139696 100644
--- a/aleksis/apps/kort/templates/kort/card/status.html
+++ b/aleksis/apps/kort/templates/kort/card/status.html
@@ -2,5 +2,5 @@
 {% if card.is_valid %}
   <span class="badge new green white-text">{% trans "Valid" %}</span>
 {% else %}
-  <span class="badge new green white-text">{% trans "Not valid" %}</span>
+  <span class="badge new red white-text">{% trans "Not valid" %}</span>
 {% endif %}
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/printer/actions.html b/aleksis/apps/kort/templates/kort/printer/actions.html
new file mode 100644
index 0000000000000000000000000000000000000000..74f1bccbaafe048280e28a20e29195c346b1a54f
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/actions.html
@@ -0,0 +1,26 @@
+{% load i18n %}
+
+<div id="detail-modal-{{ printer.pk }}" class="modal">
+  <div class="modal-content">
+    <h4>{{ printer.name }}</h4>
+    {% include "kort/printer/detail_content.html" %}
+  </div>
+  <div class="modal-footer">
+    <a href="#!" class="modal-close waves-effect waves-green btn-flat">
+      <i class="material-icons left iconify" data-icon="mdi:close"></i>
+      {% trans "Close" %}
+    </a>
+  </div>
+</div>
+<a class="btn-flat waves-effect waves-green green-text modal-trigger" href="#detail-modal-{{ printer.pk }}">
+  <i class="material-icons left iconify" data-icon="mdi:play-box-outline"></i>
+  {% trans "Show" %}
+</a>
+<a class="btn-flat waves-effect waves-orange orange-text modal-trigger" href="{% url "edit_card_printer" printer.pk %}">
+  <i class="material-icons left iconify" data-icon="mdi:pencil-outline"></i>
+  {% trans "Edit" %}
+</a>
+<a class="btn-flat waves-effect waves-red red-text modal-trigger" href="{% url "delete_card_printer" printer.pk %}">
+  <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i>
+  {% trans "Delete" %}
+</a>
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/printer/create.html b/aleksis/apps/kort/templates/kort/printer/create.html
new file mode 100644
index 0000000000000000000000000000000000000000..086400b8641fa9bf9c1d6118c8a161f266456254
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/create.html
@@ -0,0 +1,24 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n any_js %}
+
+{% block extra_head %}
+  {{ form.media.css }}
+  {% include_css "select2-materialize" %}
+{% endblock %}
+
+{% block browser_title %}{% blocktrans %}Create card{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Create card{% endblocktrans %}{% endblock %}
+
+
+{% block content %}
+  <form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+  {% include_js "select2-materialize" %}
+  {{ form.media.js }}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/printer/delete.html b/aleksis/apps/kort/templates/kort/printer/delete.html
new file mode 100644
index 0000000000000000000000000000000000000000..9fc32ae6baaede5d13e20316484705ffec3f800f
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/delete.html
@@ -0,0 +1,32 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n rules material_form %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Delete Card Printer{% endblocktrans %}{% endblock %}
+{% block no_page_title %}{% endblock %}
+
+{% block content %}
+  <p class="flow-text">{% trans "Do you really want to delete the following card printer?" %}</p>
+  {% include "kort/printer/short.html" with printer=object %}
+  <figure class="alert warning">
+    <i class="material-icons left iconify" data-icon="mdi:alert-octagon-outline"></i>
+    {% blocktrans %}
+      Please pay attention that this also will deactivate the access for the print client and you would have to
+      reconfigure the client if you want to use it further.
+    {% endblocktrans %}
+  </figure>
+  <form method="post" action="">
+    {% csrf_token %}
+    <a href="{% url "card_printers" %}" class="modal-close waves-effect waves-green btn">
+      <i class="material-icons left iconify" data-icon="mdi:arrow-left"></i>
+      {% trans "Go back" %}
+    </a>
+    <button type="submit" name="delete" class="modal-close waves-effect waves-light red btn">
+      <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i>
+      {% trans "Delete" %}
+    </button>
+  </form>
+{% endblock %}
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/printer/detail.html b/aleksis/apps/kort/templates/kort/printer/detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..303b6674c9b6941b30b8e3fba54c2b58ae6a7793
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/detail.html
@@ -0,0 +1,27 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n any_js %}
+
+
+{% block browser_title %}{% blocktrans %}Card Printer{% endblocktrans %}{% endblock %}
+{% block page_title %}{{ object.name }}{% endblock %}
+
+
+{% block content %}
+  <a class="btn waves-effect waves-light secondary margin-bottom" href="{% url "card_printers" %}">
+    <i class="material-icons left iconify" data-icon="mdi:arrow-left"></i>
+    {% trans "Back to all printers" %}
+  </a>
+  <a class="btn waves-effect waves-light margin-bottom orange modal-trigger"
+     href="{% url "edit_card_printer" object.pk %}">
+    <i class="material-icons left iconify" data-icon="mdi:pencil-outline"></i>
+    {% trans "Edit" %}
+  </a>
+  <a class="btn waves-effect waves-light margin-bottom red modal-trigger" href="{% url "delete_card_printer" object.pk %}">
+    <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i>
+    {% trans "Delete" %}
+  </a>
+  {% include "kort/printer/detail_content.html" with printer=object %}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/printer/detail_content.html b/aleksis/apps/kort/templates/kort/printer/detail_content.html
new file mode 100644
index 0000000000000000000000000000000000000000..9618a4bf1c16faa0de82b58ae6cae020f6df4200
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/detail_content.html
@@ -0,0 +1,113 @@
+{% load i18n %}
+<div class="row no-margin">
+  <div class="col s12 m12 l6 no-padding">
+    <div class="card">
+      <div class="card-content">
+        <div class="card-title">{% trans "Printer details" %}</div>
+        <table>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:printer-outline"></i>
+              {% trans "Name" %}</th>
+            <td>{{ printer.name }}</td>
+          </tr>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:map-marker-outline"></i>
+              {% trans "Location" %}
+            </th>
+            <td>{{ printer.location|default:"–" }}</td>
+          </tr>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:card-text-outline"></i>
+              {% trans "Description" %}
+            </th>
+            <td>{{ printer.description|default:"–" }}</td>
+          </tr>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:clock-check-outline"></i>
+              {% trans "Last seen at" %}
+            </th>
+            <td>{{ printer.last_seen_at|default:_("never seen yet") }}</td>
+          </tr>
+          <tr>
+            <th {% if printer.status_text %}rowspan="2"{% endif %}>
+              <i class="material-icons left iconify" data-icon="mdi:checkbox-blank-badge-outline"></i>
+              {% trans "Status" %}
+            </th>
+            <td>
+              {% include "kort/printer/status.html" %}
+            </td>
+          </tr>
+          {% if printer.status_text %}
+            <tr>
+              <td>
+                {{ printer.status_text }}
+              </td>
+            </tr>
+          {% endif %}
+        </table>
+      </div>
+    </div>
+  </div>
+  <div class="col s12 m12 l6">
+    <!-- Jobs here -->
+    {% if printer.status == "not_registered" %}
+      <div class="card">
+        <div class="card-content">
+          <div class="card-title">{% trans "Setup printer client" %}</div>
+          <p>
+            {% blocktrans %}
+              To enable printing, you have to register the print client on the device
+              which the printer is connected to.
+            {% endblocktrans %}
+          </p>
+          <h6>{% trans "1. Download print client" %}</h6>
+          <h6>{% trans "2. Download configuration file" %}</h6>
+          <a class="btn waves-effect waves-light" href="{% url "card_printer_config" printer.pk %}">
+            <i class="material-icons left iconify" data-icon="mdi:download-outline"></i>
+            {% trans "Download configuration" %}
+          </a>
+          <h6>{% trans "3. Setup client" %}</h6>
+          <code>kort-client setup {{ printer.config_filename }}</code>
+        </div>
+      </div>
+    {% else %}
+      <div class="card">
+        <div class="card-content">
+          <div class="card-title">{% trans "Print jobs" %}</div>
+          <table>
+            <tr>
+              <th>
+                {% trans "Card" %}
+              </th>
+              <th>
+                {% trans "Created at" %}
+              </th>
+              <th>
+                {% trans "Status" %}
+              </th>
+            </tr>
+            {% for job in printer.jobs.all %}
+              <tr>
+                <td>
+                  <a href="{% url "card" job.card.pk %}">
+                    {{ job.card }}
+                  </a>
+                </td>
+                <td>
+                  {{ job.created }}
+                </td>
+                <td>
+                  {{ job.status }} {% if job.status_text %}({{ job.status_text }}){% endif %}
+                </td>
+              </tr>
+            {% endfor %}
+          </table>
+        </div>
+      </div>
+    {% endif %}
+  </div>
+</div>
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/printer/edit.html b/aleksis/apps/kort/templates/kort/printer/edit.html
new file mode 100644
index 0000000000000000000000000000000000000000..450a2d022f44dcc3581c681f6279f87500f14185
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/edit.html
@@ -0,0 +1,24 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n any_js %}
+
+{% block extra_head %}
+  {{ form.media.css }}
+  {% include_css "select2-materialize" %}
+{% endblock %}
+
+{% block browser_title %}{% blocktrans %}Edit card printer{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Edit card printer{% endblocktrans %}{% endblock %}
+
+
+{% block content %}
+  <form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+    {% include "core/partials/save_button.html" %}
+  </form>
+  {% include_js "select2-materialize" %}
+  {{ form.media.js }}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/printer/list.html b/aleksis/apps/kort/templates/kort/printer/list.html
new file mode 100644
index 0000000000000000000000000000000000000000..014df87d08e4f8fdadcc590bca0ebb6fe1b87d77
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/list.html
@@ -0,0 +1,22 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n rules material_form any_js %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Card printers{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Card printers{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  {% has_perm 'core.create_cardprinter_rule' user person as can_create_person %}
+
+  {% if can_create_person %}
+    <a class="btn green waves-effect waves-light" href="{% url 'create_card_printer' %}">
+      <i class="material-icons left iconify" data-icon="mdi:plus"></i>
+      {% trans "Register new card printer" %}
+    </a>
+  {% endif %}
+
+  {% render_table table %}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/printer/short.html b/aleksis/apps/kort/templates/kort/printer/short.html
new file mode 100644
index 0000000000000000000000000000000000000000..706a5e14840f8f5cbaea409a0b76b9f77c50235d
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/short.html
@@ -0,0 +1,15 @@
+{% load i18n %}
+<table>
+  <tr>
+    <th>{% trans "Name" %}</th>
+    <td>{{ printer.name }}</td>
+  </tr>
+    <tr>
+    <th>{% trans "Location" %}</th>
+    <td>{{ printer.location|default:"–" }}</td>
+  </tr>
+  <tr>
+    <th>{% trans "Status" %}</th>
+    <td>{% include "kort/printer/status.html" %}</td>
+  </tr>
+</table>
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/printer/status.html b/aleksis/apps/kort/templates/kort/printer/status.html
new file mode 100644
index 0000000000000000000000000000000000000000..3c2a3a9f3ecf8acf5544acde08f7e466918e26df
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/printer/status.html
@@ -0,0 +1,5 @@
+{% load i18n %}
+<span class="chip white-text {{ printer.status_color }}" title="{{ printer.status_text }}">
+  <i class="material-icons left iconify" data-icon="{{ printer.status_icon }}"></i>
+  {{ printer.status_label }}
+</span>
\ No newline at end of file
diff --git a/aleksis/apps/kort/urls.py b/aleksis/apps/kort/urls.py
index 983140bc22fee78ee81eddfa486a3ca36faffb92..1cc3abcde975474cf37c389060e2a7dc1ea0544d 100644
--- a/aleksis/apps/kort/urls.py
+++ b/aleksis/apps/kort/urls.py
@@ -1,6 +1,6 @@
 from django.urls import path
 
-from . import views
+from . import api, views
 
 urlpatterns = [
     path("test", views.TestPDFView.as_view(), name="test_pdf"),
@@ -13,5 +13,41 @@ urlpatterns = [
         name="generate_card_pdf",
     ),
     path("cards/<int:pk>/deactivate/", views.CardDeactivateView.as_view(), name="deactivate_card"),
+    path("cards/<int:pk>/print/", views.CardPrintView.as_view(), name="print_card"),
     path("cards/<int:pk>/delete/", views.CardDeleteView.as_view(), name="delete_card"),
+    path("printers/", views.CardPrinterListView.as_view(), name="card_printers"),
+    path("printers/create/", views.CardPrinterCreateView.as_view(), name="create_card_printer"),
+    path("printers/<int:pk>/", views.CardPrinterDetailView.as_view(), name="card_printer"),
+    path("printers/<int:pk>/edit/", views.CardPrinterEditView.as_view(), name="edit_card_printer"),
+    path(
+        "printers/<int:pk>/delete/",
+        views.CardPrinterDeleteView.as_view(),
+        name="delete_card_printer",
+    ),
+    path(
+        "printers/<int:pk>/config/",
+        views.CardPrinterConfigView.as_view(),
+        name="card_printer_config",
+    ),
+    path("api/v1/printers/", api.CardPrinterDetails.as_view(), name="api_card_printer"),
+    path(
+        "api/v1/printers/<int:pk>/status/",
+        api.CardPrinterUpdateStatus.as_view(),
+        name="api_card_printer_status",
+    ),
+    path(
+        "api/v1/printers/<int:pk>/jobs/next/",
+        api.GetNextPrintJob.as_view(),
+        name="api_get_next_print_job",
+    ),
+    path(
+        "api/v1/jobs/<int:pk>/status/",
+        api.CardPrintJobUpdateStatusView.as_view(),
+        name="api_update_job_status",
+    ),
+    path(
+        "api/v1/jobs/<int:pk>/chip_number/",
+        api.CardPrintJobSetChipNumberView.as_view(),
+        name="api_set_chip_number",
+    ),
 ]
diff --git a/aleksis/apps/kort/views.py b/aleksis/apps/kort/views.py
index 1741e6e3bac558d9373d0b35eed4c600b409011c..31a7c42468fe4b31fbdedfc79d763ba7f17086bb 100644
--- a/aleksis/apps/kort/views.py
+++ b/aleksis/apps/kort/views.py
@@ -1,6 +1,9 @@
+import json
+
 from django.contrib import messages
+from django.db.models import Count, Q
 from django.http import HttpRequest, HttpResponse
-from django.shortcuts import redirect, render
+from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse, reverse_lazy
 from django.utils.translation import gettext as _
 from django.views import View
@@ -10,10 +13,10 @@ from django_tables2 import SingleTableView
 from reversion.views import RevisionMixin
 from rules.contrib.views import PermissionRequiredMixin
 
-from aleksis.apps.kort.forms import CardForm
-from aleksis.apps.kort.models import Card
-from aleksis.apps.kort.tables import CardTable
-from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView
+from aleksis.apps.kort.forms import CardForm, CardPrinterForm, PrinterSelectForm
+from aleksis.apps.kort.models import Card, CardPrinter, PrintStatus
+from aleksis.apps.kort.tables import CardPrinterTable, CardTable
+from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView
 from aleksis.core.util.celery_progress import render_progress_page
 from aleksis.core.views import RenderPDFView
 
@@ -31,7 +34,16 @@ class TestPDFView(RenderPDFView):
         return context
 
 
-class CardListView(PermissionRequiredMixin, RevisionMixin, SingleTableView):
+class PrinterSelectMixin:
+    def get_context_data(self, **kwargs):
+        print("Called and used?")
+        context = super().get_context_data(**kwargs)
+        context["printers"] = CardPrinter.objects.all()
+        context["printer_form"] = PrinterSelectForm()
+        return context
+
+
+class CardListView(PermissionRequiredMixin, RevisionMixin, PrinterSelectMixin, SingleTableView):
     """List view for all cards."""
 
     permission_required = "core.view_cards_rule"
@@ -75,7 +87,31 @@ class CardDeactivateView(PermissionRequiredMixin, RevisionMixin, SingleObjectMix
         return redirect(self.success_url)
 
 
-class CardDetailView(PermissionRequiredMixin, RevisionMixin, DetailView):
+class CardPrintView(PermissionRequiredMixin, RevisionMixin, SingleObjectMixin, View):
+    """View used to create a print job for a card."""
+
+    permission_required = "core.delete_card_rule"
+    model = Card
+    success_url = reverse_lazy("cards")
+
+    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+        self.object = self.get_object()
+
+        printer = self.request.GET.get("printer")
+        printer = get_object_or_404(CardPrinter, pk=printer)
+
+        self.object.print_card(printer)
+        messages.success(
+            request,
+            _(
+                "The print job for the card {} on the printer {} has been created successfully."
+            ).format(self.object.person, printer.name),
+        )
+
+        return redirect(self.success_url)
+
+
+class CardDetailView(PermissionRequiredMixin, RevisionMixin, PrinterSelectMixin, DetailView):
     permission_required = "core.view_card_rule"
     model = Card
     template_name = "kort/card/detail.html"
@@ -110,3 +146,84 @@ class CardGeneratePDFView(PermissionRequiredMixin, RevisionMixin, SingleObjectMi
             button_url=redirect_url,
             button_icon="credit_card",
         )
+
+
+class CardPrinterListView(PermissionRequiredMixin, RevisionMixin, SingleTableView):
+    """List view for all card printers."""
+
+    permission_required = "core.view_cardprinters_rule"
+    template_name = "kort/printer/list.html"
+    model = CardPrinter
+    table_class = CardPrinterTable
+
+    def get_queryset(self):
+        return CardPrinter.objects.all().annotate(
+            jobs_count=Count(
+                "jobs", filter=~Q(jobs__status__in=[PrintStatus.FINISHED, PrintStatus.FAILED])
+            )
+        )
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        CardPrinter.check_online_status_for_all()
+        return context
+
+
+class CardPrinterCreateView(PermissionRequiredMixin, RevisionMixin, AdvancedCreateView):
+    """View used to create a card printer."""
+
+    permission_required = "core.create_cardprinter_rule"
+    template_name = "kort/printer/create.html"
+    form_class = CardPrinterForm
+    model = CardPrinter
+    success_message = _("The card printer has been created successfully.")
+
+    def get_success_url(self):
+        return reverse("card_printer", args=[self.object.pk])
+
+
+class CardPrinterEditView(PermissionRequiredMixin, RevisionMixin, AdvancedEditView):
+    """View used to edit a card printer."""
+
+    permission_required = "core.edit_cardprinter_rule"
+    template_name = "kort/printer/edit.html"
+    form_class = CardPrinterForm
+    model = CardPrinter
+    success_message = _("The card printer has been changed successfully.")
+
+    def get_success_url(self):
+        return reverse("card_printer", args=[self.object.pk])
+
+
+class CardPrinterDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView):
+    """View used to delete a card printer."""
+
+    permission_required = "core.delete_cardprinter_rule"
+    success_url = reverse_lazy("card_printers")
+    template_name = "kort/printer/delete.html"
+    model = CardPrinter
+    success_message = _("The card printer has been deleted successfully.")
+
+
+class CardPrinterDetailView(PermissionRequiredMixin, RevisionMixin, DetailView):
+    permission_required = "core.view_cardprinter_rule"
+    model = CardPrinter
+    template_name = "kort/printer/detail.html"
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        self.object.check_online_status()
+        return context
+
+
+class CardPrinterConfigView(PermissionRequiredMixin, RevisionMixin, SingleObjectMixin, View):
+    permission_required = "core.view_cardprinter_rule"
+    model = CardPrinter
+
+    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+        self.object = self.get_object()
+        response = HttpResponse(
+            json.dumps(self.object.generate_config()), content_type="application/json"
+        )
+        response["Content-Disposition"] = f'attachment; filename="{self.object.config_filename}"'
+        return response