From 1744cf49b3ccf3763afb13666584fda9e1922032 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Mon, 2 Nov 2020 17:40:49 +0100
Subject: [PATCH 2/2] cards: add category (#48111)

---
 tests/admin_pages/test_all.py                 |   7 +-
 tests/admin_pages/test_card.py                |  81 +++++++
 tests/admin_pages/test_carddefcategory.py     | 212 ++++++++++++++++++
 tests/backoffice_pages/test_all.py            |  51 ++++-
 tests/test_api.py                             |  17 +-
 tests/test_carddef.py                         |  58 +++++
 tests/test_categories.py                      |  98 ++++----
 wcs/admin/categories.py                       |  76 +++++--
 wcs/admin/forms.py                            |  89 +++-----
 wcs/admin/settings.py                         |   7 +-
 wcs/api.py                                    |   2 +
 wcs/backoffice/cards.py                       |  44 ++--
 wcs/backoffice/data_management.py             |  17 +-
 wcs/carddef.py                                |   3 +
 wcs/categories.py                             |  17 +-
 wcs/formdef.py                                |   8 +-
 .../wcs/backoffice/data-management.html       |  10 +-
 17 files changed, 640 insertions(+), 157 deletions(-)
 create mode 100644 tests/admin_pages/test_carddefcategory.py

diff --git a/tests/admin_pages/test_all.py b/tests/admin_pages/test_all.py
index fe60decc..50354bd6 100644
--- a/tests/admin_pages/test_all.py
+++ b/tests/admin_pages/test_all.py
@@ -23,7 +23,7 @@ from wcs.qommon.ident.password_accounts import PasswordAccount
 from wcs.qommon.http_request import HTTPRequest
 from wcs.qommon.template import get_current_theme
 from wcs.admin.settings import UserFieldsFormDef
-from wcs.categories import Category
+from wcs.categories import Category, CardDefCategory
 from wcs.data_sources import NamedDataSource
 from wcs.wscalls import NamedWsCall
 from wcs.roles import Role
@@ -641,6 +641,7 @@ def test_settings_export_import(pub, studio):
         Workflow.wipe()
         Role.wipe()
         Category.wipe()
+        CardDefCategory.wipe()
         NamedDataSource.wipe()
         NamedWsCall.wipe()
 
@@ -672,6 +673,7 @@ def test_settings_export_import(pub, studio):
     carddef.name = 'bar'
     carddef.store()
     Category(name='baz').store()
+    CardDefCategory(name='foobar').store()
     Role(name='qux').store()
     NamedDataSource(name='quux').store()
     NamedWsCall(name='corge').store()
@@ -708,10 +710,11 @@ def test_settings_export_import(pub, studio):
     assert 'models/export_to_model-1.upload' not in filelist
     assert 'roles/1' in filelist
     assert 'categories/1' in filelist
+    assert 'carddef_categories/1' in filelist
     assert 'datasources/1' in filelist
     assert 'wscalls/corge' in filelist
     for filename in filelist:
-        assert not '.indexes' in filename
+        assert '.indexes' not in filename
 
     wipe()
     assert FormDef.count() == 0
diff --git a/tests/admin_pages/test_card.py b/tests/admin_pages/test_card.py
index 0e3b98f1..94207933 100644
--- a/tests/admin_pages/test_card.py
+++ b/tests/admin_pages/test_card.py
@@ -4,6 +4,7 @@ import pytest
 
 from wcs import fields
 from wcs.admin.settings import UserFieldsFormDef
+from wcs.categories import CardDefCategory
 from wcs.carddef import CardDef
 from wcs.formdef import FormDef
 from wcs.qommon.http_request import HTTPRequest
@@ -42,6 +43,47 @@ def teardown_module(module):
     clean_temporary_pub()
 
 
+def test_cards_list(pub, studio):
+    create_superuser(pub)
+
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'card title'
+    carddef.fields = []
+    carddef.store()
+
+    carddef2 = CardDef()
+    carddef2.name = 'card title 2'
+    carddef2.fields = []
+    carddef2.store()
+
+    CardDefCategory.wipe()
+    cat = CardDefCategory(name='Foo')
+    cat.store()
+    cat2 = CardDefCategory(name='Bar')
+    cat2.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/cards/')
+    assert '<h2>Misc</h2>' not in resp.text
+    assert '<h2>Foo</h2>' not in resp.text
+    assert '<h2>Bar</h2>' not in resp.text
+
+    carddef.category = cat2
+    carddef.store()
+    resp = app.get('/backoffice/cards/')
+    assert '<h2>Misc</h2>' in resp.text
+    assert '<h2>Foo</h2>' not in resp.text
+    assert '<h2>Bar</h2>' in resp.text
+
+    carddef2.category = cat
+    carddef2.store()
+    resp = app.get('/backoffice/cards/')
+    assert '<h2>Misc</h2>' not in resp.text
+    assert '<h2>Foo</h2>' in resp.text
+    assert '<h2>Bar</h2>' in resp.text
+
+
 def test_cards_new(pub, studio):
     CardDef.wipe()
     create_superuser(pub)
@@ -197,6 +239,45 @@ def test_card_digest_template(pub, studio):
     assert 'Existing cards will be updated in the background.' not in resp.text
 
 
+def test_card_category(pub, studio):
+    create_superuser(pub)
+
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'card title'
+    carddef.fields = []
+    carddef.store()
+
+    CardDefCategory.wipe()
+    cat = CardDefCategory(name='Foo')
+    cat.store()
+    cat = CardDefCategory(name='Bar')
+    cat.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/cards/1/')
+    assert '<span class="label">Category</span> <span class="value">None</span>' in resp.text
+    assert '<span class="label">Category</span> <span class="value">Foo</span>' not in resp.text
+    assert '<span class="label">Category</span> <span class="value">Bar</span>' not in resp.text
+    resp = resp.click(href='category')
+    resp.forms[0].submit('cancel')
+    assert CardDef.get(carddef.id).category_id is None
+
+    resp = app.get('/backoffice/cards/1/')
+    assert '<span class="label">Category</span> <span class="value">None</span>' in resp.text
+    assert '<span class="label">Category</span> <span class="value">Foo</span>' not in resp.text
+    assert '<span class="label">Category</span> <span class="value">Bar</span>' not in resp.text
+    resp = resp.click(href='category')
+    resp.forms[0]['category_id'] = cat.id
+    resp.forms[0].submit('submit')
+    assert CardDef.get(carddef.id).category_id == cat.id
+
+    resp = app.get('/backoffice/cards/1/')
+    assert '<span class="label">Category</span> <span class="value">None</span>' not in resp.text
+    assert '<span class="label">Category</span> <span class="value">Foo</span>' not in resp.text
+    assert '<span class="label">Category</span> <span class="value">Bar</span>' in resp.text
+
+
 def test_card_custom_view_data_source(pub, studio):
     user = create_superuser(pub)
     Role.wipe()
diff --git a/tests/admin_pages/test_carddefcategory.py b/tests/admin_pages/test_carddefcategory.py
new file mode 100644
index 00000000..321df5d9
--- /dev/null
+++ b/tests/admin_pages/test_carddefcategory.py
@@ -0,0 +1,212 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+
+from wcs.qommon.http_request import HTTPRequest
+from wcs.carddef import CardDef
+from wcs.categories import CardDefCategory
+
+from utilities import get_app, login, create_temporary_pub, clean_temporary_pub
+from .test_all import create_superuser
+
+
+def pytest_generate_tests(metafunc):
+    if 'pub' in metafunc.fixturenames:
+        metafunc.parametrize('pub', ['pickle', 'sql', 'pickle-templates'], indirect=True)
+
+
+@pytest.fixture
+def pub(request):
+    pub = create_temporary_pub(
+            sql_mode=bool('sql' in request.param),
+            templates_mode=bool('templates' in request.param)
+            )
+
+    req = HTTPRequest(None, {'SCRIPT_NAME': '/', 'SERVER_NAME': 'example.net'})
+    pub.set_app_dir(req)
+    pub.cfg['identification'] = {'methods': ['password']}
+    pub.cfg['language'] = {'language': 'en'}
+    pub.write_cfg()
+
+    return pub
+
+
+def teardown_module(module):
+    clean_temporary_pub()
+
+
+def test_categories(pub):
+    create_superuser(pub)
+    app = login(get_app(pub))
+    app.get('/backoffice/cards/categories/')
+
+
+def test_categories_new(pub):
+    create_superuser(pub)
+    CardDefCategory.wipe()
+    app = login(get_app(pub))
+
+    # go to the page and cancel
+    resp = app.get('/backoffice/cards/categories/')
+    resp = resp.click('New Category')
+    resp = resp.forms[0].submit('cancel')
+    assert resp.location == 'http://example.net/backoffice/cards/categories/'
+
+    # go to the page and add a category
+    resp = app.get('/backoffice/cards/categories/')
+    resp = resp.click('New Category')
+    resp.forms[0]['name'] = 'a new category'
+    resp.forms[0]['description'] = 'description of the category'
+    resp = resp.forms[0].submit('submit')
+    assert resp.location == 'http://example.net/backoffice/cards/categories/'
+    resp = resp.follow()
+    assert 'a new category' in resp.text
+    resp = resp.click('a new category')
+    assert '<h2>a new category' in resp.text
+
+    assert CardDefCategory.get(1).name == 'a new category'
+    assert CardDefCategory.get(1).description == 'description of the category'
+
+
+def test_categories_edit(pub):
+    create_superuser(pub)
+    CardDefCategory.wipe()
+    category = CardDefCategory(name='foobar')
+    category.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/cards/categories/1/')
+    assert 'no card model associated to this category' in resp.text
+
+    resp = resp.click(href='edit')
+    assert resp.forms[0]['name'].value == 'foobar'
+    resp.forms[0]['description'] = 'category description'
+    resp = resp.forms[0].submit('submit')
+    assert resp.location == 'http://example.net/backoffice/cards/categories/'
+    resp = resp.follow()
+    resp = resp.click('foobar')
+    assert '<h2>foobar' in resp.text
+
+    assert CardDefCategory.get(1).description == 'category description'
+
+
+def test_categories_edit_duplicate_name(pub):
+    CardDefCategory.wipe()
+    category = CardDefCategory(name='foobar')
+    category.store()
+    category = CardDefCategory(name='foobar2')
+    category.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/cards/categories/1/')
+
+    resp = resp.click(href='edit')
+    assert resp.forms[0]['name'].value == 'foobar'
+    resp.forms[0]['name'] = 'foobar2'
+    resp = resp.forms[0].submit('submit')
+    assert 'This name is already used' in resp.text
+
+    resp = resp.forms[0].submit('cancel')
+    assert resp.location == 'http://example.net/backoffice/cards/categories/'
+
+
+def test_categories_with_carddefs(pub):
+    CardDefCategory.wipe()
+    category = CardDefCategory(name='foobar')
+    category.store()
+
+    CardDef.wipe()
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/cards/categories/1/')
+    assert 'form bar' not in resp.text
+
+    formdef = CardDef()
+    formdef.name = 'form bar'
+    formdef.fields = []
+    formdef.category_id = category.id
+    formdef.store()
+
+    resp = app.get('/backoffice/cards/categories/1/')
+    assert 'form bar' in resp.text
+    assert 'no card model associated to this category' not in resp.text
+
+
+def test_categories_delete(pub):
+    CardDefCategory.wipe()
+    category = CardDefCategory(name='foobar')
+    category.store()
+
+    CardDef.wipe()
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/cards/categories/1/')
+
+    resp = resp.click(href='delete')
+    resp = resp.forms[0].submit('cancel')
+    assert resp.location == 'http://example.net/backoffice/cards/categories/1/'
+    assert CardDefCategory.count() == 1
+
+    resp = app.get('/backoffice/cards/categories/1/')
+    resp = resp.click(href='delete')
+    resp = resp.forms[0].submit()
+    assert resp.location == 'http://example.net/backoffice/cards/categories/'
+    resp = resp.follow()
+    assert CardDefCategory.count() == 0
+
+
+def test_categories_edit_description(pub):
+    CardDefCategory.wipe()
+    category = CardDefCategory(name='foobar')
+    category.description = 'category description'
+    category.store()
+
+    app = login(get_app(pub))
+    # this URL is used for editing from the frontoffice, there's no link
+    # pointing to it in the admin.
+    resp = app.get('/backoffice/cards/categories/1/description')
+    assert resp.forms[0]['description'].value == 'category description'
+    resp.forms[0]['description'] = 'updated description'
+
+    # check cancel doesn't save the change
+    resp2 = resp.forms[0].submit('cancel')
+    assert resp2.location == 'http://example.net/backoffice/cards/categories/1/'
+    assert CardDefCategory.get(1).description == 'category description'
+
+    # check submit does it properly
+    resp2 = resp.forms[0].submit('submit')
+    assert resp2.location == 'http://example.net/backoffice/cards/categories/1/'
+    resp2 = resp2.follow()
+    assert CardDefCategory.get(1).description == 'updated description'
+
+
+def test_categories_new_duplicate_name(pub):
+    CardDefCategory.wipe()
+    category = CardDefCategory(name='foobar')
+    category.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/cards/categories/')
+    resp = resp.click('New Category')
+    resp.forms[0]['name'] = 'foobar'
+    resp = resp.forms[0].submit('submit')
+    assert 'This name is already used' in resp.text
+
+
+def test_categories_reorder(pub):
+    CardDefCategory.wipe()
+    category = CardDefCategory(name='foo')
+    category.store()
+    category = CardDefCategory(name='bar')
+    category.store()
+    category = CardDefCategory(name='baz')
+    category.store()
+
+    app = login(get_app(pub))
+    app.get('/backoffice/cards/categories/update_order?order=1;2;3;')
+    categories = CardDefCategory.select()
+    CardDefCategory.sort_by_position(categories)
+    assert [x.id for x in categories] == ['1', '2', '3']
+
+    app.get('/backoffice/cards/categories/update_order?order=3;1;2;')
+    categories = CardDefCategory.select()
+    CardDefCategory.sort_by_position(categories)
+    assert [x.id for x in categories] == ['3', '1', '2']
diff --git a/tests/backoffice_pages/test_all.py b/tests/backoffice_pages/test_all.py
index a83ae81b..692b73be 100644
--- a/tests/backoffice_pages/test_all.py
+++ b/tests/backoffice_pages/test_all.py
@@ -46,7 +46,7 @@ from wcs.wf.resubmit import ResubmitWorkflowStatusItem
 from wcs.wf.create_formdata import CreateFormdataWorkflowStatusItem, Mapping
 from wcs.wf.create_carddata import CreateCarddataWorkflowStatusItem
 from wcs.carddef import CardDef
-from wcs.categories import Category
+from wcs.categories import Category, CardDefCategory
 from wcs.formdef import FormDef
 from wcs.logged_errors import LoggedError
 from wcs import fields
@@ -6144,7 +6144,7 @@ def test_carddata_management(pub, studio):
     user = create_user(pub)
     app = login(get_app(pub))
     resp = app.get('/backoffice/')
-    assert not 'Cards' in resp.text
+    assert 'Cards' not in resp.text
     carddef = CardDef()
     carddef.name = 'foo'
     carddef.fields = [
@@ -6156,7 +6156,7 @@ def test_carddata_management(pub, studio):
     carddef.data_class().wipe()
 
     resp = app.get('/backoffice/')
-    assert not 'Cards' in resp.text
+    assert 'Cards' not in resp.text
 
     carddef.backoffice_submission_roles = user.roles
     carddef.store()
@@ -6212,6 +6212,51 @@ def test_carddata_management(pub, studio):
     assert resp.text.count('<tr') == 2  # header + row of data
 
 
+def test_carddata_management_categories(pub, studio):
+    user = create_user(pub)
+
+    CardDef.wipe()
+    carddef = CardDef()
+    carddef.name = 'foo'
+    carddef.fields = []
+    carddef.backoffice_submission_roles = None
+    carddef.workflow_roles = {'_editor': user.roles[0]}
+    carddef.store()
+
+    carddef2 = CardDef()
+    carddef2.name = 'card title 2'
+    carddef2.fields = []
+    carddef2.backoffice_submission_roles = None
+    carddef2.workflow_roles = {'_editor': user.roles[0]}
+    carddef2.store()
+
+    CardDefCategory.wipe()
+    cat = CardDefCategory(name='Foo')
+    cat.store()
+    cat2 = CardDefCategory(name='Bar')
+    cat2.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/data/')
+    assert '<h3>Misc</h3>' not in resp.text
+    assert '<h3>Foo</h3>' not in resp.text
+    assert '<h3>Bar</h3>' not in resp.text
+
+    carddef.category = cat2
+    carddef.store()
+    resp = app.get('/backoffice/data/')
+    assert '<h3>Misc</h3>' in resp.text
+    assert '<h3>Foo</h3>' not in resp.text
+    assert '<h3>Bar</h3>' in resp.text
+
+    carddef2.category = cat
+    carddef2.store()
+    resp = app.get('/backoffice/data/')
+    assert '<h3>Misc</h3>' not in resp.text
+    assert '<h3>Foo</h3>' in resp.text
+    assert '<h3>Bar</h3>' in resp.text
+
+
 def test_studio_card_item_link(pub, studio):
     user = create_user(pub)
     CardDef.wipe()
diff --git a/tests/test_api.py b/tests/test_api.py
index 0b1d9230..8641ccb4 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -31,7 +31,7 @@ from wcs.roles import Role
 from wcs.carddef import CardDef
 from wcs.formdef import FormDef
 from wcs.formdata import Evolution
-from wcs.categories import Category
+from wcs.categories import Category, CardDefCategory
 from wcs.data_sources import NamedDataSource
 from wcs.workflows import Workflow, EditableWorkflowStatusItem, WorkflowBackofficeFieldsFormDef, WorkflowVariablesFieldsFormDef
 from wcs.wf.jump import JumpWorkflowStatusItem
@@ -3312,6 +3312,11 @@ def test_cards(pub, local_user):
     local_user.roles = [role.id]
     local_user.store()
 
+    CardDefCategory.wipe()
+    category = CardDefCategory()
+    category.name = 'Category A'
+    category.store()
+
     CardDef.wipe()
     carddef = CardDef()
     carddef.name = 'test'
@@ -3355,11 +3360,21 @@ def test_cards(pub, local_user):
     resp = get_app(pub).get(sign_uri('/api/cards/@list'))
     assert len(resp.json['data']) == 1
     assert resp.json['data'][0]['slug'] == 'test'
+    assert resp.json['data'][0]['category_slug'] is None
+    assert resp.json['data'][0]['category_name'] is None
     assert resp.json['data'][0]['custom_views'] == [
         {'id': 'datasource-carddef-custom-view', 'text': 'datasource carddef custom view'},
         {'id': 'shared-carddef-custom-view', 'text': 'shared carddef custom view'},
     ]
 
+    carddef.category = category
+    carddef.store()
+    resp = get_app(pub).get(sign_uri('/api/cards/@list'))
+    assert len(resp.json['data']) == 1
+    assert resp.json['data'][0]['slug'] == 'test'
+    assert resp.json['data'][0]['category_slug'] == 'category-a'
+    assert resp.json['data'][0]['category_name'] == 'Category A'
+
     resp = get_app(pub).get(sign_uri('/api/cards/test/list'), status=403)
 
     resp = get_app(pub).get(sign_uri(
diff --git a/tests/test_carddef.py b/tests/test_carddef.py
index b23277fb..7f73d089 100644
--- a/tests/test_carddef.py
+++ b/tests/test_carddef.py
@@ -4,7 +4,9 @@ import xml.etree.ElementTree as ET
 from django.utils.six import BytesIO
 
 from wcs.qommon.http_request import HTTPRequest
+from wcs.qommon.misc import indent_xml as indent
 from wcs.qommon.template import Template
+from wcs.categories import CardDefCategory
 from wcs.carddef import CardDef
 from wcs.fields import ItemField
 from wcs.fields import StringField
@@ -31,6 +33,28 @@ def teardown_module(module):
     clean_temporary_pub()
 
 
+def export_to_indented_xml(carddef, include_id=False):
+    carddef_xml = ET.fromstring(ET.tostring(carddef.export_to_xml(include_id=include_id)))
+    indent(carddef_xml)
+    return carddef_xml
+
+
+def assert_compare_carddef(carddef1, carddef2, include_id=False):
+    assert (
+        ET.tostring(export_to_indented_xml(carddef1, include_id=include_id)) ==
+        ET.tostring(export_to_indented_xml(carddef2, include_id=include_id)))
+    assert (
+        carddef1.export_to_json(include_id=include_id, indent=2) ==
+        carddef2.export_to_json(include_id=include_id, indent=2))
+
+
+def assert_xml_import_export_works(carddef, include_id=False):
+    carddef_xml = carddef.export_to_xml(include_id=include_id)
+    carddef2 = CardDef.import_from_xml_tree(carddef_xml, include_id=include_id)
+    assert_compare_carddef(carddef, carddef2, include_id=include_id)
+    return carddef2
+
+
 def test_basics(pub):
     carddef = CardDef()
     carddef.name = 'foo'
@@ -159,6 +183,40 @@ def test_xml_export_import(pub):
     assert custom_views[1].formdef_type == 'carddef'
 
 
+def test_xml_export_import_category_reference(pub):
+    CardDefCategory.wipe()
+    CardDef.wipe()
+
+    cat = CardDefCategory()
+    cat.name = 'test category'
+    cat.store()
+
+    carddef = CardDef()
+    carddef.name = 'foo'
+    carddef.category_id = cat.id
+    f2 = assert_xml_import_export_works(carddef)
+    assert f2.category_id == carddef.category_id
+
+    f2 = assert_xml_import_export_works(carddef, include_id=True)
+    assert f2.category_id == carddef.category_id
+
+    carddef_xml_with_id = carddef.export_to_xml(include_id=True)
+
+    # check there's no reference to a non-existing category
+    CardDefCategory.wipe()
+    assert CardDef.import_from_xml_tree(carddef_xml_with_id, include_id=False).category_id is None
+    assert CardDef.import_from_xml_tree(carddef_xml_with_id, include_id=True).category_id is None
+
+    # check an import that is not using id fields will find the category by its
+    # name
+    cat = CardDefCategory()
+    cat.id = '2'
+    cat.name = 'test category'
+    cat.store()
+    assert CardDef.import_from_xml_tree(carddef_xml_with_id, include_id=False).category_id == '2'
+    assert CardDef.import_from_xml_tree(carddef_xml_with_id, include_id=True).category_id is None
+
+
 def test_template_access(pub):
     CardDef.wipe()
     carddef = CardDef()
diff --git a/tests/test_categories.py b/tests/test_categories.py
index b31549f2..707ceb8d 100644
--- a/tests/test_categories.py
+++ b/tests/test_categories.py
@@ -6,9 +6,8 @@ import pytest
 
 from django.utils.six import BytesIO
 from quixote import cleanup
-from wcs import publisher
 
-from wcs.categories import Category
+from wcs.categories import Category, CardDefCategory
 
 from utilities import create_temporary_pub
 
@@ -25,49 +24,53 @@ def teardown_module(module):
     shutil.rmtree(pub.APP_DIR)
 
 
-def test_store():
-    Category.wipe()
-    test = Category()
+@pytest.mark.parametrize('category_class', [Category, CardDefCategory])
+def test_store(category_class):
+    category_class.wipe()
+    test = category_class()
     test.name = 'Test'
     test.description = 'Hello world'
     test.store()
-    test2 = Category.get(1)
+    test2 = category_class.get(1)
     assert test.id == test2.id
     assert test.name == test2.name
     assert test.description == test2.description
 
 
-def test_urlname():
-    Category.wipe()
-    test = Category()
+@pytest.mark.parametrize('category_class', [Category, CardDefCategory])
+def test_urlname(category_class):
+    category_class.wipe()
+    test = category_class()
     test.name = 'Test'
     test.description = 'Hello world'
     test.store()
-    test = Category.get(1)
+    test = category_class.get(1)
     assert test.url_name == 'test'
 
 
-def test_duplicate_urlname():
-    Category.wipe()
-    test = Category()
+@pytest.mark.parametrize('category_class', [Category, CardDefCategory])
+def test_duplicate_urlname(category_class):
+    category_class.wipe()
+    test = category_class()
     test.name = 'Test'
     test.store()
-    test = Category.get(1)
+    test = category_class.get(1)
     assert test.url_name == 'test'
 
-    test2 = Category()
+    test2 = category_class()
     test2.name = 'Test'
     test2.store()
-    test2 = Category.get(2)
+    test2 = category_class.get(2)
     assert test2.url_name == 'test-2'
 
 
-def test_sort_positions():
-    Category.wipe()
+@pytest.mark.parametrize('category_class', [Category, CardDefCategory])
+def test_sort_positions(category_class):
+    category_class.wipe()
 
     categories = []
     for i in range(10):
-        test = Category()
+        test = category_class()
         test.name = 'Test %s' % i
         test.position = 10-i
         categories.append(test)
@@ -76,35 +79,37 @@ def test_sort_positions():
     for i in range(8, 10):
         categories[i].position = None
 
-    Category.sort_by_position(categories)
+    category_class.sort_by_position(categories)
     assert categories[0].name == 'Test 7'
     assert categories[-1].name in ('Test 8', 'Test 9')
 
 
-def test_xml_export():
-    Category.wipe()
-    test = Category()
+@pytest.mark.parametrize('category_class', [Category, CardDefCategory])
+def test_xml_export(category_class):
+    category_class.wipe()
+    test = category_class()
     test.id = 1
     test.name = 'Test'
     test.description = 'Hello world'
     test.store()
-    test = Category.get(1)
+    test = category_class.get(1)
 
     assert b'<name>Test</name>' in test.export_to_xml_string(include_id=True)
     assert b' id="1"' in test.export_to_xml_string(include_id=True)
     assert b' id="1"' not in test.export_to_xml_string(include_id=False)
 
 
-def test_xml_import():
-    Category.wipe()
-    test = Category()
+@pytest.mark.parametrize('category_class', [Category, CardDefCategory])
+def test_xml_import(category_class):
+    category_class.wipe()
+    test = category_class()
     test.name = 'Test'
     test.description = 'Hello world'
     test.store()
-    test = Category.get(1)
+    test = category_class.get(1)
 
     fd = BytesIO(test.export_to_xml_string(include_id=True))
-    test2 = Category.import_from_xml(fd, include_id=True)
+    test2 = category_class.import_from_xml(fd, include_id=True)
     assert test.id == test2.id
     assert test.name == test2.name
     assert test.description == test2.description
@@ -129,40 +134,43 @@ def test_load_old_pickle():
     assert test.description == test2.description
 
 
-def test_get_by_urlname():
-    Category.wipe()
-    test = Category()
+@pytest.mark.parametrize('category_class', [Category, CardDefCategory])
+def test_get_by_urlname(category_class):
+    category_class.wipe()
+    test = category_class()
     test.id = 1
     test.name = 'Test'
     test.description = 'Hello world'
     test.store()
-    test = Category.get(1)
-    test2 = Category.get_by_urlname('test')
+    test = category_class.get(1)
+    test2 = category_class.get_by_urlname('test')
     assert test.id == test2.id
 
 
-def test_has_urlname():
-    Category.wipe()
-    test = Category()
+@pytest.mark.parametrize('category_class', [Category, CardDefCategory])
+def test_has_urlname(category_class):
+    category_class.wipe()
+    test = category_class()
     test.id = 1
     test.name = 'Test'
     test.description = 'Hello world'
     test.store()
-    test = Category.get(1)
+    test = category_class.get(1)
 
-    assert Category.has_urlname('test')
-    assert not Category.has_urlname('foobar')
+    assert category_class.has_urlname('test')
+    assert not category_class.has_urlname('foobar')
 
 
-def test_remove_self():
-    Category.wipe()
-    test = Category()
+@pytest.mark.parametrize('category_class', [Category, CardDefCategory])
+def test_remove_self(category_class):
+    category_class.wipe()
+    test = category_class()
     test.id = 1
     test.name = 'Test'
     test.description = 'Hello world'
     test.store()
-    test = Category.get(1)
+    test = category_class.get(1)
     test.remove_self()
 
     with pytest.raises(KeyError):
-        Category.get(1)
+        category_class.get(1)
diff --git a/wcs/admin/categories.py b/wcs/admin/categories.py
index 06907434..ae600052 100644
--- a/wcs/admin/categories.py
+++ b/wcs/admin/categories.py
@@ -18,19 +18,22 @@ from quixote import redirect
 from quixote.directory import Directory
 from quixote.html import TemplateIO, htmltext
 
-from wcs.qommon import _
-from wcs.categories import Category
+from wcs.qommon import _, N_
+from wcs.categories import Category, CardDefCategory
 from wcs.qommon.form import *
 from wcs.qommon.backoffice.menu import html_top
 
+from wcs.carddef import CardDef
 from wcs.formdef import FormDef
 
 
 class CategoryUI(object):
+    category_class = Category
+
     def __init__(self, category):
         self.category = category
         if self.category is None:
-            self.category = Category()
+            self.category = self.category_class()
 
     def get_form(self):
         form = Form(enctype='multipart/form-data')
@@ -39,10 +42,11 @@ class CategoryUI(object):
         form.add(WysiwygTextWidget, 'description', title=_('Description'),
                 cols=80, rows=10,
                 value=self.category.description)
-        form.add(StringWidget, 'redirect_url', size=32,
-                title=_('URL Redirection'),
-                hint=_('If set, redirect the site category page to the given URL.'),
-                value=self.category.redirect_url)
+        if self.category_class == Category:
+            form.add(StringWidget, 'redirect_url', size=32,
+                    title=_('URL Redirection'),
+                    hint=_('If set, redirect the site category page to the given URL.'),
+                    value=self.category.redirect_url)
         form.add_submit('submit', _('Submit'))
         form.add_submit('cancel', _('Cancel'))
         return form
@@ -51,22 +55,32 @@ class CategoryUI(object):
         self.category.name = form.get_widget('name').parse()
 
         name = form.get_widget('name').parse()
-        category_names = [x.name for x in Category.select() if x.id != self.category.id]
+        category_names = [x.name for x in self.category_class.select() if x.id != self.category.id]
         if name in category_names:
             form.get_widget('name').set_error(_('This name is already used'))
             raise ValueError()
 
         self.category.description = form.get_widget('description').parse()
-        self.category.redirect_url = form.get_widget('redirect_url').parse()
+        if form.get_widget('redirect_url'):
+            self.category.redirect_url = form.get_widget('redirect_url').parse()
         self.category.store()
 
 
+class CardDefCategoryUI(CategoryUI):
+    category_class = CardDefCategory
+
+
 class CategoryPage(Directory):
+    category_class = Category
+    category_ui_class = CategoryUI
+    formdef_class = FormDef
+    usage_title = N_('Forms in this category')
+    empty_message = N_('no form associated to this category')
     _q_exports = ['', 'edit', 'delete', 'description']
 
     def __init__(self, component):
-        self.category = Category.get(component)
-        self.category_ui = CategoryUI(self.category)
+        self.category = self.category_class.get(component)
+        self.category_ui = self.category_ui_class(self.category)
         get_response().breadcrumb.append((component + '/', self.category.name))
 
     def _q_index(self):
@@ -86,17 +100,17 @@ class CategoryPage(Directory):
             r += self.category.get_description_html_text()
             r += htmltext('</div>')
 
-        formdefs = FormDef.select(order_by='name')
+        formdefs = self.formdef_class.select(order_by='name')
         formdefs = [x for x in formdefs if x.category_id == self.category.id]
         r += htmltext('<div class="bo-block">')
-        r += htmltext('<h3>%s</h3>') % _('Forms in this category')
+        r += htmltext('<h3>%s</h3>') % _(self.usage_title)
         r += htmltext('<ul>')
         for formdef in formdefs:
             r += htmltext('<li><a href="../../%s/">') % str(formdef.id)
             r += formdef.name
             r += htmltext('</a></li>')
         if not formdefs:
-            r += htmltext('<li>%s</li>') % _('no form associated to this category')
+            r += htmltext('<li>%s</li>') % _(self.empty_message)
         r += htmltext('</ul>')
         r += htmltext('</div>')
         return r.getvalue()
@@ -165,8 +179,20 @@ class CategoryPage(Directory):
         return r.getvalue()
 
 
+class CardDefCategoryPage(CategoryPage):
+    category_class = CardDefCategory
+    category_ui_class = CardDefCategoryUI
+    formdef_class = CardDef
+    usage_title = N_('Card models in this category')
+    empty_message = N_('no card model associated to this category')
+
+
 class CategoriesDirectory(Directory):
     _q_exports = ['', 'new', 'update_order']
+    category_class = Category
+    category_ui_class = CategoryUI
+    category_page_class = CategoryPage
+    category_explanation = N_('Categories are used to sort the different forms.')
 
     def _q_index(self):
         get_response().add_javascript(['jquery.js', 'jquery-ui.js', 'biglist.js',
@@ -180,11 +206,10 @@ class CategoriesDirectory(Directory):
         r += htmltext('<a class="new-item" href="new" rel="popup">%s</a>') % _('New Category')
         r += htmltext('</span>')
         r += htmltext('</div>')
-        r += htmltext('<div class="explanation bo-block"><p>%s</p></div>') % \
-                _('Categories are used to sort the different forms.')
-        categories = Category.select()
+        r += htmltext('<div class="explanation bo-block"><p>%s</p></div>') % _(self.category_explanation)
+        categories = self.category_class.select()
         r += htmltext('<ul class="biglist sortable" id="category-list">')
-        Category.sort_by_position(categories)
+        self.category_class.sort_by_position(categories)
         for category in categories:
             r += htmltext('<li class="biglistitem" id="itemId_%s">') % category.id
             r += htmltext('<strong class="label"><a href="%s/">%s</a></strong>') % (
@@ -196,7 +221,7 @@ class CategoriesDirectory(Directory):
     def update_order(self):
         request = get_request()
         new_order = request.form['order'].strip(';').split(';')
-        categories = Category.select()
+        categories = self.category_class.select()
         dict = {}
         for l in categories:
             dict[str(l.id)] = l
@@ -207,7 +232,7 @@ class CategoriesDirectory(Directory):
 
     def new(self):
         get_response().breadcrumb.append( ('new', _('New')) )
-        category_ui = CategoryUI(None)
+        category_ui = self.category_ui_class(None)
         form = category_ui.get_form()
         if form.get_widget('cancel').parse():
             return redirect('.')
@@ -227,8 +252,15 @@ class CategoriesDirectory(Directory):
         return r.getvalue()
 
     def _q_lookup(self, component):
-        return CategoryPage(component)
+        return self.category_page_class(component)
 
     def _q_traverse(self, path):
         get_response().breadcrumb.append( ('categories/', _('Categories')) )
-        return super(CategoriesDirectory, self)._q_traverse(path)
+        return super()._q_traverse(path)
+
+
+class CardDefCategoriesDirectory(CategoriesDirectory):
+    category_class = CardDefCategory
+    category_ui_class = CardDefCategoryUI
+    category_page_class = CardDefCategoryPage
+    category_explanation = N_('Categories are used to sort the different card models.')
diff --git a/wcs/admin/forms.py b/wcs/admin/forms.py
index 071ef4a7..527b6806 100644
--- a/wcs/admin/forms.py
+++ b/wcs/admin/forms.py
@@ -60,19 +60,20 @@ from .logged_errors import LoggedErrorsDirectory
 from wcs.backoffice.snapshots import SnapshotsDirectory
 
 
-def get_categories():
-    t = sorted([(misc.simplify(x.name), x.id, x.name, x.id) for x in Category.select()])
+def get_categories(category_class):
+    t = sorted([(misc.simplify(x.name), x.id, x.name, x.id) for x in category_class.select()])
     return [x[1:] for x in t]
 
 
 class FormDefUI(object):
     formdef_class = FormDef
+    category_class = Category
 
     def __init__(self, formdef):
         self.formdef = formdef
 
     def get_categories(self):
-        return get_categories()
+        return get_categories(self.category_class)
 
     @classmethod
     def get_workflows(cls, condition=lambda x: True):
@@ -165,6 +166,8 @@ class FieldsDirectory(FieldsDirectory):
 
 
 class OptionsDirectory(Directory):
+    category_class = Category
+    category_empty_choice = N_('Select a category for this form')
     _q_exports = ['confirmation', 'only_allow_one',
             'always_advertise', 'tracking_code', 'online_status', 'captcha',
             'description', 'keywords', 'category', 'management',
@@ -265,9 +268,9 @@ class OptionsDirectory(Directory):
         return self.handle(form, _('Keywords'))
 
     def category(self):
-        categories = get_categories()
+        categories = get_categories(self.category_class)
         form = Form(enctype='multipart/form-data')
-        form.widgets.append(HtmlWidget('<p>%s</p>' % _('Select a category for this form')))
+        form.widgets.append(HtmlWidget('<p>%s</p>' % _(self.category_empty_choice)))
         form.add(SingleSelectWidget, 'category_id', title=_('Category'),
                value=self.formdef.category_id,
                options=[(None, '---', '')] + categories)
@@ -421,6 +424,7 @@ class FormDefPage(Directory):
     formdef_export_prefix = 'form'
     formdef_ui_class = FormDefUI
     formdef_default_workflow = '_default'
+    options_directory_class = OptionsDirectory
 
     delete_message = N_('You are about to irrevocably delete this form.')
     delete_title = N_('Deleting Form:')
@@ -442,7 +446,7 @@ class FormDefPage(Directory):
         self.fields.html_top = self.html_top
         self.role = WorkflowRoleDirectory(self.formdef)
         self.role.html_top = self.html_top
-        self.options = OptionsDirectory(self.formdef)
+        self.options = self.options_directory_class(self.formdef)
         self.logged_errors_dir = LoggedErrorsDirectory(parent_dir=self, formdef_id=self.formdef.id)
         self.snapshots_dir = SnapshotsDirectory(self.formdef)
 
@@ -714,31 +718,6 @@ class FormDefPage(Directory):
         r += htmltext('</div>')
         return r.getvalue()
 
-    def category(self):
-        categories = get_categories()
-        form = Form(enctype='multipart/form-data')
-        form.add(SingleSelectWidget, 'category_id',
-                        value=self.formdef.category_id,
-                        options=[(None, '---', '')] + categories)
-        if not self.formdef.is_readonly():
-            form.add_submit('submit', _('Submit'))
-        form.add_submit('cancel', _('Cancel'))
-        if form.get_widget('cancel').parse():
-            return redirect('.')
-
-        if not form.is_submitted() or form.has_errors():
-            get_response().breadcrumb.append( ('category', _('Category')) )
-            self.html_top(title = self.formdef.name)
-            r = TemplateIO(html=True)
-            r += htmltext('<h2>%s</h2>') % _('Category')
-            r += htmltext('<p>%s</p>') % _('Select a category for this form')
-            r += form.render()
-            return r.getvalue()
-        else:
-            self.formdef.category_id = form.get_widget('category_id').parse()
-            self.formdef.store(comment=_('Change of category'))
-            return redirect('.')
-
     def _roles_selection(self, title, attribute, description=None,
             include_logged_users_role=True):
         form = Form(enctype='multipart/form-data')
@@ -1564,6 +1543,7 @@ class FormsDirectory(AccessControlled, Directory):
     _q_exports = ['', 'new', ('import', 'p_import'),
             'blocks', 'categories', ('data-sources', 'data_sources')]
 
+    category_class = Category
     categories = CategoriesDirectory()
     blocks = BlocksDirectory(section='forms')
     data_sources = NamedDataSourcesDirectoryInForms()
@@ -1571,6 +1551,7 @@ class FormsDirectory(AccessControlled, Directory):
     formdef_page_class = FormDefPage
     formdef_ui_class = FormDefUI
 
+    top_title = N_('Forms')
     import_title = N_('Import Form')
     import_submit_label = N_('Import Form')
     import_paragraph = N_(
@@ -1593,30 +1574,13 @@ class FormsDirectory(AccessControlled, Directory):
         return super()._q_traverse(path)
 
     def _q_index(self):
-        self.html_top(title = _('Forms'))
+        self.html_top(title=_(self.top_title))
         r = TemplateIO(html=True)
         get_response().add_javascript(['jquery.js', 'widget_list.js'])
-        has_roles = bool(Role.count())
+        r += self.form_actions()
 
-        r += htmltext('<div id="appbar">')
-        r += htmltext('<h2>%s</h2>') % _('Forms')
-        if has_roles:
-            r += htmltext('<span class="actions">')
-            r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
-            if get_publisher().has_site_option('fields-blocks'):
-                r += htmltext('<a href="blocks/">%s</a>') % _('Fields blocks')
-            if get_publisher().get_backoffice_root().is_accessible('categories'):
-                r += htmltext('<a href="categories/">%s</a>') % _('Categories')
-            r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
-            r += htmltext('<a class="new-item" href="new" rel="popup">%s</a>') % _('New Form')
-            r += htmltext('</span>')
-        r += htmltext('</div>')
-
-        if not has_roles:
-            r += htmltext('<p>%s</p>') % _('You first have to define roles.')
-
-        cats = Category.select()
-        Category.sort_by_position(cats)
+        cats = self.category_class.select()
+        self.category_class.sort_by_position(cats)
         one = False
         formdefs = self.formdef_class.select(order_by='name', ignore_errors=True, lightweight=True)
         for c in cats:
@@ -1638,6 +1602,27 @@ class FormsDirectory(AccessControlled, Directory):
             r += self.form_list(l2, title = title)
         return r.getvalue()
 
+    def form_actions(self):
+        r = TemplateIO(html=True)
+        has_roles = bool(Role.count())
+        r += htmltext('<div id="appbar">')
+        r += htmltext('<h2>%s</h2>') % _('Forms')
+        if has_roles:
+            r += htmltext('<span class="actions">')
+            r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
+            if get_publisher().has_site_option('fields-blocks'):
+                r += htmltext('<a href="blocks/">%s</a>') % _('Fields blocks')
+            if get_publisher().get_backoffice_root().is_accessible('categories'):
+                r += htmltext('<a href="categories/">%s</a>') % _('Categories')
+            r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
+            r += htmltext('<a class="new-item" href="new" rel="popup">%s</a>') % _('New Form')
+            r += htmltext('</span>')
+        r += htmltext('</div>')
+
+        if not has_roles:
+            r += htmltext('<p>%s</p>') % _('You first have to define roles.')
+        return r.getvalue()
+
     def form_list(self, formdefs, title):
         r = TemplateIO(html=True)
         if title:
diff --git a/wcs/admin/settings.py b/wcs/admin/settings.py
index b1ea9892..3961d2ae 100644
--- a/wcs/admin/settings.py
+++ b/wcs/admin/settings.py
@@ -867,6 +867,7 @@ class SettingsDirectory(QommonSettingsDirectory):
         if not get_cfg('sp', {}).get('idp-manage-roles'):
             form.add(CheckboxWidget, 'roles', title = _('Roles'), value = True)
         form.add(CheckboxWidget, 'categories', title = _('Categories'), value = True)
+        form.add(CheckboxWidget, 'carddef_categories', title = _('Card Model Categories'), value = True)
         form.add(CheckboxWidget, 'settings', title = _('Settings'), value = False)
         form.add(CheckboxWidget, 'datasources', title=_('Data sources'), value=True)
         form.add(CheckboxWidget, 'mail-templates', title=_('Mail templates'), value=True)
@@ -894,7 +895,7 @@ class SettingsDirectory(QommonSettingsDirectory):
                 c = BytesIO()
                 z = zipfile.ZipFile(c, 'w')
                 for d in self.dirs:
-                    if d not in ('roles', 'categories', 'datasources', 'wscalls', 'mail-templates'):
+                    if d not in ('roles', 'categories', 'carddef_categories', 'datasources', 'wscalls', 'mail-templates'):
                         continue
                     path = os.path.join(self.app_dir, d)
                     if not os.path.exists(path):
@@ -942,8 +943,8 @@ class SettingsDirectory(QommonSettingsDirectory):
                 job.store()
 
         dirs = []
-        for w in ('formdefs', 'carddefs', 'workflows', 'roles', 'categories',
-                'datasources', 'wscalls', 'mail-templates', 'blockdefs'):
+        for w in ('formdefs', 'carddefs', 'workflows', 'roles', 'categories', 'carddef_categories',
+                  'datasources', 'wscalls', 'mail-templates', 'blockdefs'):
             if form.get_widget(w) and form.get_widget(w).parse():
                 dirs.append(w)
         if not dirs and not form.get_widget('settings').parse():
diff --git a/wcs/api.py b/wcs/api.py
index 416927e0..41a83882 100644
--- a/wcs/api.py
+++ b/wcs/api.py
@@ -342,6 +342,8 @@ class ApiCardsDirectory(Directory):
                 'title': x.name,
                 'slug': x.url_name,
                 'url': x.get_url(),
+                'category_slug': x.category.url_name if x.category else None,
+                'category_name': x.category.name if x.category else None,
                 'description': x.description or '',
                 'keywords': x.keywords_list,
                 'custom_views': get_custom_views(x),
diff --git a/wcs/backoffice/cards.py b/wcs/backoffice/cards.py
index 3d97a653..75d02460 100644
--- a/wcs/backoffice/cards.py
+++ b/wcs/backoffice/cards.py
@@ -16,29 +16,32 @@
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
-import time
-
-from quixote import get_publisher, get_request, get_response, get_session, redirect
+from quixote import get_publisher, get_response, get_session, redirect
 from quixote.directory import Directory
 from quixote.html import TemplateIO, htmltext
 
-from ..qommon import _, N_, misc
+from ..qommon import _, N_
 from ..qommon.misc import C_
 from ..qommon.storage import NotEqual, Null
 
 from wcs.carddef import CardDef
+from wcs.categories import CardDefCategory
 from wcs.roles import Role
 from wcs.workflows import Workflow
-from wcs.admin.forms import FormsDirectory, FormDefPage, FormDefUI, html_top
+from wcs.admin.categories import CardDefCategoriesDirectory
+from wcs.admin.forms import FormsDirectory, FormDefPage, FormDefUI, html_top, OptionsDirectory
 from wcs.admin.logged_errors import LoggedErrorsDirectory
 from wcs.admin import utils
 
 
 class CardDefUI(FormDefUI):
     formdef_class = CardDef
+    category_class = CardDefCategory
+
 
-    def get_categories(self):
-        return []
+class CardDefOptionsDirectory(OptionsDirectory):
+    category_class = CardDefCategory
+    category_empty_choice = N_('Select a category for this card model')
 
 
 class CardDefPage(FormDefPage):
@@ -47,6 +50,8 @@ class CardDefPage(FormDefPage):
     formdef_ui_class = CardDefUI
     formdef_default_workflow = '_carddef_default'
 
+    options_directory_class = CardDefOptionsDirectory
+
     delete_message = N_('You are about to irrevocably delete this card model.')
     delete_title = N_('Deleting Card Model:')
     overwrite_message = N_(
@@ -86,6 +91,17 @@ class CardDefPage(FormDefPage):
                               'label': label,
                               'current_value': current_value})
 
+        r += htmltext('<div class="bo-block">')
+        r += htmltext('<h3>%s</h3>') % _('Information')
+        r += htmltext('<ul class="biglist optionslist">')
+
+        r += add_option_line(
+            'options/category', _('Category'),
+            self.formdef.category_id and self.formdef.category and
+            self.formdef.category.name or C_('category|None'))
+        r += htmltext('</ul>')
+        r += htmltext('</div>')
+
         r += htmltext('<div class="splitcontent-left">')
         r += htmltext('<div class="bo-block">')
         r += htmltext('<h3>%s</h3>') % _('Workflow')
@@ -214,11 +230,15 @@ class CardDefPage(FormDefPage):
 
 
 class CardsDirectory(FormsDirectory):
-    _q_exports = ['', 'new', ('import', 'p_import')]
+    _q_exports = ['', 'new', ('import', 'p_import'), 'categories']
+
+    category_class = CardDefCategory
+    categories = CardDefCategoriesDirectory()
     formdef_class = CardDef
     formdef_page_class = CardDefPage
     formdef_ui_class = CardDefUI
 
+    top_title = N_('Card Models')
     import_title = N_('Import Card Model')
     import_submit_label = N_('Import Card Model')
     import_paragraph = N_(
@@ -238,20 +258,16 @@ class CardsDirectory(FormsDirectory):
         get_response().breadcrumb.append(('cards/', _('Card Models')))
         return Directory._q_traverse(self, path)
 
-    def _q_index(self):
-        self.html_top(title=_('Card Models'))
+    def form_actions(self):
         r = TemplateIO(html=True)
-
         r += htmltext('<div id="appbar">')
         r += htmltext('<h2>%s</h2>') % _('Card Models')
         r += htmltext('<span class="actions">')
+        r += htmltext('<a href="categories/">%s</a>') % _('Categories')
         r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
         r += htmltext('<a class="new-item" href="new" rel="popup">%s</a>') % _('New Card Model')
         r += htmltext('</span>')
         r += htmltext('</div>')
-
-        formdefs = self.formdef_class.select(order_by='name', ignore_errors=True, lightweight=True)
-        r += self.form_list(formdefs, title='')
         return r.getvalue()
 
     def new(self):
diff --git a/wcs/backoffice/data_management.py b/wcs/backoffice/data_management.py
index 915ce1b7..073ef862 100644
--- a/wcs/backoffice/data_management.py
+++ b/wcs/backoffice/data_management.py
@@ -33,6 +33,7 @@ from ..qommon import template
 from ..qommon.afterjobs import AfterJob
 
 from wcs.carddef import CardDef
+from wcs.categories import CardDefCategory
 from wcs import fields
 
 from .management import ManagementDirectory, FormPage, FormFillPage, FormBackOfficeStatusPage
@@ -60,16 +61,22 @@ class DataManagementDirectory(ManagementDirectory):
 
     def get_carddefs(self):
         user = get_request().user
-        if user:
-            for formdef in CardDef.select(order_by='name', ignore_errors=True, lightweight=True):
-                if user.is_admin or formdef.is_of_concern_for_user(user):
-                    yield formdef
+        if not user:
+            return
+        carddefs = CardDef.select(order_by='name', ignore_errors=True, lightweight=True)
+        carddefs = [c for c in carddefs if user.is_admin or c.is_of_concern_for_user(user)]
+        cats = CardDefCategory.select(order_by='name')
+        for c in cats + [None]:
+            for carddef in carddefs:
+                if c is None and not carddef.category_id:
+                    yield carddef
+                if c is not None and carddef.category_id == c.id:
+                    yield carddef
 
     def _q_index(self):
         html_top('data_management', _('Cards'))
         if CardDef.count() == 0:
             return self.empty_site_message(_('Cards'))
-
         return template.QommonTemplateResponse(
                 templates=['wcs/backoffice/data-management.html'],
                 context={'view': self})
diff --git a/wcs/carddef.py b/wcs/carddef.py
index 21d05753..1c688ce5 100644
--- a/wcs/carddef.py
+++ b/wcs/carddef.py
@@ -23,6 +23,7 @@ from .qommon.storage import Contains, Equal, NotEqual, ILike
 from .qommon.template import Template
 
 from wcs.carddata import CardData
+from wcs.categories import CardDefCategory
 from wcs.formdef import FormDef, get_formdefs_of_all_kinds
 
 if not hasattr(types, 'ClassType'):
@@ -39,6 +40,8 @@ class CardDef(FormDef):
 
     confirmation = False
 
+    category_class = CardDefCategory
+
     def data_class(self, mode=None):
         if not 'carddef' in sys.modules:
             sys.modules['carddef'] = sys.modules[__name__]
diff --git a/wcs/categories.py b/wcs/categories.py
index d9036120..954122c9 100644
--- a/wcs/categories.py
+++ b/wcs/categories.py
@@ -14,7 +14,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
-from quixote import get_publisher, get_response
+from quixote import get_publisher
 from quixote.html import htmltext
 
 from .qommon import N_
@@ -34,10 +34,11 @@ class Category(XmlStorableObject):
     redirect_url = None
 
     # declarations for serialization
-    XML_NODES = [('name', 'str'), ('url_name', 'str'), ('description', 'str'),
-            ('redirect_url', 'str'), ('position', 'int')]
+    XML_NODES = [
+        ('name', 'str'), ('url_name', 'str'), ('description', 'str'),
+        ('redirect_url', 'str'), ('position', 'int')]
 
-    def __init__(self, name = None):
+    def __init__(self, name=None):
         StorableObject.__init__(self)
         self.name = name
 
@@ -110,6 +111,14 @@ class Category(XmlStorableObject):
         return htmltext(text)
 
 
+class CardDefCategory(Category):
+    _names = 'carddef_categories'
+    xml_root_node = 'carddef_category'
+
+    # declarations for serialization
+    XML_NODES = [('name', 'str'), ('url_name', 'str'), ('description', 'str'), ('position', 'int')]
+
+
 Substitutions.register('category_name', category=N_('General'), comment=N_('Category Name'))
 Substitutions.register('category_description', category=N_('General'), comment=N_('Category Description'))
 Substitutions.register('category_id', category=N_('General'), comment=N_('Category Identifier'))
diff --git a/wcs/formdef.py b/wcs/formdef.py
index 859a947d..16d2ff56 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -141,6 +141,8 @@ class FormDef(StorableObject):
             'always_advertise', 'include_download_all_button',
             'has_captcha', 'skip_from_360_view']
 
+    category_class = Category
+
     def __init__(self, *args, **kwargs):
         super(FormDef, self).__init__(*args, **kwargs)
         self.fields = []
@@ -415,7 +417,7 @@ class FormDef(StorableObject):
     def get_category(self):
         if self.category_id:
             try:
-                return Category.get(self.category_id)
+                return self.category_class.get(self.category_id)
             except KeyError:
                 return None
         else:
@@ -1183,11 +1185,11 @@ class FormDef(StorableObject):
             category_node = tree.find('category')
             if include_id and category_node.attrib.get('category_id'):
                 category_id = str(category_node.attrib.get('category_id'))
-                if Category.has_key(category_id):
+                if cls.category_class.has_key(category_id):
                     formdef.category_id = category_id
             else:
                 category = xml_node_text(category_node)
-                for c in Category.select():
+                for c in cls.category_class.select():
                     if c.name == category:
                         formdef.category_id = c.id
                         break
diff --git a/wcs/templates/wcs/backoffice/data-management.html b/wcs/templates/wcs/backoffice/data-management.html
index 16ea190e..a5064b9e 100644
--- a/wcs/templates/wcs/backoffice/data-management.html
+++ b/wcs/templates/wcs/backoffice/data-management.html
@@ -4,9 +4,13 @@
 {% block appbar-title %}{% trans "Cards" %}{% endblock %}
 
 {% block content %}
-<ul class="objects-list single-links">
-  {% for carddef in view.get_carddefs %}
-  <li><a href="{{ carddef.url_name }}/">{{ carddef.name }}</a></li>
+{% regroup view.get_carddefs by category as category_list %}
+<ul class="biglist">
+  {% for category, object_list in category_list %}
+    {% if not forloop.first or category %}<li><h3>{{ category.name|default:_('Misc') }}</h3></li>{% endif %}
+    {% for carddef in object_list %}
+    <li><a href="{{ carddef.url_name }}/">{{ carddef.name }}</a></li>
+    {% endfor %}
   {% endfor %}
 </ul>
 {% endblock %}
-- 
2.28.0

