diff --git a/conntrackt/forms.py b/conntrackt/forms.py
new file mode 100644
--- /dev/null
+++ b/conntrackt/forms.py
@@ -0,0 +1,32 @@
+# Django imports.
+from django.forms import ModelForm
+from django.forms.models import inlineformset_factory
+
+# Application imports.
+from .models import Entity, Interface
+
+
+class EntityForm(ModelForm):
+ """
+ Implements a custom model form for entities with some styling changes.
+ """
+
+ class Meta:
+ model = Entity
+
+ def __init__(self, *args, **kwargs):
+ """
+ Initialises the form instance. Sets-up some bootstrap CSS classes for
+ widgets.
+ """
+
+ super(EntityForm, self).__init__(*args, **kwargs)
+
+ # Update the widgets to be wider, and set-up placeholder values for text
+ # boxes.
+ self.fields["name"].widget.attrs["class"] = "span6"
+ self.fields["name"].widget.attrs["placeholder"] = "Entity name"
+ self.fields["description"].widget.attrs["class"] = "span6"
+ self.fields["description"].widget.attrs["placeholder"] = "Description for new entity."
+ self.fields["project"].widget.attrs["class"] = "span6"
+ self.fields["location"].widget.attrs["class"] = "span6"
diff --git a/conntrackt/templates/conntrackt/entity_create_form.html b/conntrackt/templates/conntrackt/entity_create_form.html
new file mode 100644
--- /dev/null
+++ b/conntrackt/templates/conntrackt/entity_create_form.html
@@ -0,0 +1,27 @@
+{% extends "conntrackt/base.html" %}
+
+{# For html_link #}
+{% load conntrackt_tags %}
+{# For Bootstrapped forms #}
+{% load crispy_forms_tags %}
+
+{% block content %}
+
+
Add new entity
+
+
+
+{% endblock content %}
diff --git a/conntrackt/templates/conntrackt/project_detail.html b/conntrackt/templates/conntrackt/project_detail.html
--- a/conntrackt/templates/conntrackt/project_detail.html
+++ b/conntrackt/templates/conntrackt/project_detail.html
@@ -1,9 +1,12 @@
{% extends "conntrackt/base.html" %}
+{# For html_link #}
{% load conntrackt_tags %}
{% block content %}
-{{project.name}}
+
+
{{project.name}}
+
{% if project.description %}
@@ -18,6 +21,9 @@
{% html_link "Edit" "project_update" project.id class="btn btn-primary" %}
{% html_link "Remove" "project_delete" project.id class="btn btn-primary" %}
+ {% with project_id=project.id|slugify %}
+ {% html_link "Add entity" "entity_create" class="btn btn-primary" get="project="|add:project_id %}
+ {% endwith %}
diff --git a/conntrackt/tests/test_forms.py b/conntrackt/tests/test_forms.py
--- a/conntrackt/tests/test_forms.py
+++ b/conntrackt/tests/test_forms.py
@@ -0,0 +1,23 @@
+# Django imports.
+from django.test import TestCase
+
+# Application imports.
+from conntrackt.forms import EntityForm
+
+
+class EntityFormTest(TestCase):
+ """
+ Tests for the custom Entity model form.
+ """
+
+ def test_styling(self):
+ """
+ Test that the form styling is set-up correctly.
+ """
+
+ form = EntityForm()
+
+ self.assertIn("span6", form.fields["name"].widget.attrs["class"])
+ self.assertIn("span6", form.fields["description"].widget.attrs["class"])
+ self.assertIn("span6", form.fields["project"].widget.attrs["class"])
+ self.assertIn("span6", form.fields["location"].widget.attrs["class"])
diff --git a/conntrackt/tests/test_views.py b/conntrackt/tests/test_views.py
--- a/conntrackt/tests/test_views.py
+++ b/conntrackt/tests/test_views.py
@@ -4,12 +4,14 @@ from zipfile import ZipFile, ZIP_DEFLATE
# Django imports.
from django.core.urlresolvers import reverse
+from django.test import RequestFactory
from django.test import TestCase
from django.test.client import Client
from django.contrib.auth.models import User, Permission
# Application imports
from conntrackt.models import Project, Location
+from conntrackt.views import EntityCreateView
class ViewTest(TestCase):
@@ -746,3 +748,104 @@ class LocationDeleteViewTest(TestCase):
follow=True)
self.assertContains(response, "Location Test Location 1 has been removed.")
+
+
+class EntityCreateViewTest(TestCase):
+
+ def setUp(self):
+ """
+ Sets-up some data necessary for testing.
+ """
+
+ # Set-up some data for testing.
+ Project.objects.create(name="Test Project 1", description="This is test project 1.")
+ Project.objects.create(name="Test Project 2", description="This is test project 2.")
+ Location.objects.create(name="Test Location 1", description="This is test location 1.")
+ Location.objects.create(name="Test Location 2", description="This is test location 2.")
+
+ def test_permission_denied(self):
+ """
+ Tests if permission will be denied for client without sufficient privileges.
+ """
+
+ User.objects.create_user("noperms", "noperms@example.com", "noperms")
+
+ self.client.login(username="noperms", password="noperms")
+
+ response = self.client.get(reverse("entity_create"))
+
+ self.assertContains(response, "You have insufficient privileges to access this resource. Please contact your local system administrator if you believe you should have been granted access.", status_code=403)
+
+ def test_permission_granted(self):
+ """
+ Tests if permission will be granted for user with correct privileges.
+ """
+
+ user = User.objects.create_user("fullperms", "fullperms@example.com", "fullperms")
+ user.user_permissions.add(Permission.objects.get(codename="add_entity"))
+
+ self.client.login(username="fullperms", password="fullperms")
+
+ response = self.client.get(reverse("entity_create"))
+
+ self.assertEqual(response.status_code, 200)
+
+ def test_form_project_limit(self):
+ """
+ Tests if the queryset is properly limitted to specific project if GET
+ parameters is passed.
+ """
+
+ # Set-up the view.
+ view = EntityCreateView()
+ view.request = RequestFactory().get("/fake-path?project=1")
+ view.object = None
+
+ # Get the form.
+ form = view.get_form(view.get_form_class())
+
+ self.assertQuerysetEqual(form.fields["project"].queryset, [""])
+
+ def test_form_location_limit(self):
+ """
+ Tests if the queryset is properly limitted to specific location if GET
+ parameters is passed.
+ """
+
+ # Set-up the view.
+ view = EntityCreateView()
+ view.request = RequestFactory().get("/fake-path?location=1")
+ view.object = None
+
+ # Get the form.
+ form = view.get_form(view.get_form_class())
+
+ self.assertQuerysetEqual(form.fields["location"].queryset, [""])
+
+ def test_initial_project(self):
+ """
+ Tests if the choice field for project is defaulted to project passed as
+ part of GET parameters.
+ """
+
+ view = EntityCreateView()
+ view.request = RequestFactory().get("/fake-path?project=1")
+ view.object = None
+
+ initial = view.get_initial()
+
+ self.assertDictContainsSubset({"project": "1"}, initial)
+
+ def test_initial_location(self):
+ """
+ Tests if the choice field for location is defaulted to location passed
+ as part of GET parameters.
+ """
+
+ view = EntityCreateView()
+ view.request = RequestFactory().get("/fake-path?location=1")
+ view.object = None
+
+ initial = view.get_initial()
+
+ self.assertDictContainsSubset({"location": "1"}, initial)
diff --git a/conntrackt/urls.py b/conntrackt/urls.py
--- a/conntrackt/urls.py
+++ b/conntrackt/urls.py
@@ -6,6 +6,7 @@ from django.contrib.auth.views import lo
from .views import IndexView, EntityView, entity_iptables, project_iptables
from .views import ProjectView, ProjectCreateView, ProjectUpdateView, ProjectDeleteView
from .views import LocationCreateView, LocationUpdateView, LocationDeleteView
+from .views import EntityCreateView
urlpatterns = patterns(
@@ -34,6 +35,9 @@ urlpatterns = patterns(
# View for showing information about an entity.
url(r'^entity/(?P\d+)/$', EntityView.as_view(),
name='entity'),
+ # View for creating a new entity.
+ url(r'^entity/add/$', EntityCreateView.as_view(), name="entity_create"),
+
# View for rendering iptables rules for a specific entity.
url(r'^entity/(?P\d+)/iptables/$', entity_iptables, name="entity_iptables"),
# View for rendering zip file with iptables rules for all entities in a project.
diff --git a/conntrackt/views.py b/conntrackt/views.py
--- a/conntrackt/views.py
+++ b/conntrackt/views.py
@@ -14,6 +14,7 @@ from django.views.generic import Templat
from braces.views import MultiplePermissionsRequiredMixin
# Application imports.
+from .forms import EntityForm
from .models import Project, Entity, Location
from .utils import generate_entity_iptables
@@ -417,3 +418,58 @@ class LocationDeleteView(MultiplePermiss
messages.success(self.request, "Location %s has been removed." % self.get_object().name, extra_tags="alert alert-success")
return super(LocationDeleteView, self).post(*args, **kwargs)
+
+
+class EntityCreateView(MultiplePermissionsRequiredMixin, CreateView):
+ """
+ View for creating a new entity.
+ """
+
+ model = Entity
+ form_class = EntityForm
+ template_name_suffix = "_create_form"
+
+ # Required permissions.
+ permissions = {
+ "all": ("conntrackt.add_entity",),
+ }
+
+ # Raise authorisation denied exception for unmet permissions.
+ raise_exception = True
+
+ def get_form(self, form_class):
+ """
+ Returns an instance of form that can be used by the view.
+
+ The method will limit the project or location select inputs if request
+ contained this information.
+ """
+
+ form = super(EntityCreateView, self).get_form(form_class)
+
+ # Limit the project selection if required.
+ project_id = self.request.GET.get("project", None)
+ if project_id:
+ form.fields["project"].queryset = Project.objects.filter(pk=project_id)
+ form.fields["project"].widget.attrs["readonly"] = True
+
+ # Limit the location selection if required.
+ location_id = self.request.GET.get("location", None)
+ if location_id:
+ form.fields["location"].queryset = Location.objects.filter(pk=location_id)
+ form.fields["location"].widget.attrs["readonly"] = True
+
+ return form
+
+ def get_initial(self):
+ """
+ Returns initial values that should be pre-selected (if they were
+ specified through a GET parameter).
+ """
+
+ initial = super(EntityCreateView, self).get_initial()
+
+ initial["project"] = self.request.GET.get("project", None)
+ initial["location"] = self.request.GET.get("location", None)
+
+ return initial