You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@bloodhound.apache.org by gj...@apache.org on 2020/09/23 20:27:44 UTC

[bloodhound-core] 01/14: add branch for new bh_core experiment

This is an automated email from the ASF dual-hosted git repository.

gjm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/bloodhound-core.git

commit 0394b68f7b4bd03e97d4911d358339043fbd67b2
Author: Gary Martin <gj...@apache.org>
AuthorDate: Mon Jun 4 13:50:34 2018 +0000

    add branch for new bh_core experiment
    
    git-svn-id: https://svn.apache.org/repos/asf/bloodhound/branches/bh_core_experimental@1832850 13f79535-47bb-0310-9956-ffa450edef68
---
 Pipfile                     |  16 +++++
 Pipfile.lock                | 144 ++++++++++++++++++++++++++++++++++++++++++++
 README.md                   |  91 ++++++++++++++++++++++++++++
 bh_core/__init__.py         |  17 ++++++
 bh_core/settings.py         | 142 +++++++++++++++++++++++++++++++++++++++++++
 bh_core/urls.py             |  40 ++++++++++++
 bh_core/wsgi.py             |  33 ++++++++++
 functional_tests.py         |  39 ++++++++++++
 manage.py                   |  33 ++++++++++
 pytest.ini                  |   2 +
 trackers/__init__.py        |  16 +++++
 trackers/admin.py           |  21 +++++++
 trackers/apps.py            |  22 +++++++
 trackers/fixtures/empty.yml |   1 +
 trackers/models.py          |  80 ++++++++++++++++++++++++
 trackers/tests.py           |  93 ++++++++++++++++++++++++++++
 trackers/urls.py            |  23 +++++++
 trackers/views.py           |  22 +++++++
 18 files changed, 835 insertions(+)

diff --git a/Pipfile b/Pipfile
new file mode 100644
index 0000000..e912a7b
--- /dev/null
+++ b/Pipfile
@@ -0,0 +1,16 @@
+[[source]]
+url = "https://pypi.python.org/simple"
+verify_ssl = true
+name = "pypi"
+
+[dev-packages]
+selenium = "*"
+pytest-django = "*"
+PyYAML = "*"
+
+[packages]
+django = ">=2.0.0"
+pyyaml = "*"
+
+[requires]
+python_version = "3.6"
diff --git a/Pipfile.lock b/Pipfile.lock
new file mode 100644
index 0000000..4836a5b
--- /dev/null
+++ b/Pipfile.lock
@@ -0,0 +1,144 @@
+{
+    "_meta": {
+        "hash": {
+            "sha256": "4e791c7fd7b1d7f8749e94bcf56e4f78fb6514b5cf0d3174fe26d91f92cb672d"
+        },
+        "pipfile-spec": 6,
+        "requires": {
+            "python_version": "3.6"
+        },
+        "sources": [
+            {
+                "name": "pypi",
+                "url": "https://pypi.python.org/simple",
+                "verify_ssl": true
+            }
+        ]
+    },
+    "default": {
+        "django": {
+            "hashes": [
+                "sha256:3eb25c99df1523446ec2dc1b00e25eb2ecbdf42c9d8b0b8b32a204a8db9011f8",
+                "sha256:69ff89fa3c3a8337015478a1a0744f52a9fef5d12c1efa01a01f99bcce9bf10c"
+            ],
+            "index": "pypi",
+            "version": "==2.0.6"
+        },
+        "pytz": {
+            "hashes": [
+                "sha256:65ae0c8101309c45772196b21b74c46b2e5d11b6275c45d251b150d5da334555",
+                "sha256:c06425302f2cf668f1bba7a0a03f3c1d34d4ebeef2c72003da308b3947c7f749"
+            ],
+            "version": "==2018.4"
+        },
+        "pyyaml": {
+            "hashes": [
+                "sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
+                "sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
+                "sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
+                "sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
+                "sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
+                "sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
+                "sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7",
+                "sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
+                "sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
+                "sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
+                "sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
+                "sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
+                "sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
+                "sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269"
+            ],
+            "index": "pypi",
+            "version": "==3.12"
+        }
+    },
+    "develop": {
+        "atomicwrites": {
+            "hashes": [
+                "sha256:240831ea22da9ab882b551b31d4225591e5e447a68c5e188db5b89ca1d487585",
+                "sha256:a24da68318b08ac9c9c45029f4a10371ab5b20e4226738e150e6e7c571630ae6"
+            ],
+            "version": "==1.1.5"
+        },
+        "attrs": {
+            "hashes": [
+                "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265",
+                "sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b"
+            ],
+            "version": "==18.1.0"
+        },
+        "more-itertools": {
+            "hashes": [
+                "sha256:2b6b9893337bfd9166bee6a62c2b0c9fe7735dcf85948b387ec8cba30e85d8e8",
+                "sha256:6703844a52d3588f951883005efcf555e49566a48afd4db4e965d69b883980d3",
+                "sha256:a18d870ef2ffca2b8463c0070ad17b5978056f403fb64e3f15fe62a52db21cc0"
+            ],
+            "version": "==4.2.0"
+        },
+        "pluggy": {
+            "hashes": [
+                "sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff",
+                "sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c",
+                "sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5"
+            ],
+            "version": "==0.6.0"
+        },
+        "py": {
+            "hashes": [
+                "sha256:29c9fab495d7528e80ba1e343b958684f4ace687327e6f789a94bf3d1915f881",
+                "sha256:983f77f3331356039fdd792e9220b7b8ee1aa6bd2b25f567a963ff1de5a64f6a"
+            ],
+            "version": "==1.5.3"
+        },
+        "pytest": {
+            "hashes": [
+                "sha256:39555d023af3200d004d09e51b4dd9fdd828baa863cded3fd6ba2f29f757ae2d",
+                "sha256:c76e93f3145a44812955e8d46cdd302d8a45fbfc7bf22be24fe231f9d8d8853a"
+            ],
+            "version": "==3.6.0"
+        },
+        "pytest-django": {
+            "hashes": [
+                "sha256:534505e0261cc566279032d9d887f844235342806fd63a6925689670fa1b29d7",
+                "sha256:7501942093db2250a32a4e36826edfc542347bb9b26c78ed0649cdcfd49e5789"
+            ],
+            "index": "pypi",
+            "version": "==3.2.1"
+        },
+        "pyyaml": {
+            "hashes": [
+                "sha256:0c507b7f74b3d2dd4d1322ec8a94794927305ab4cebbe89cc47fe5e81541e6e8",
+                "sha256:16b20e970597e051997d90dc2cddc713a2876c47e3d92d59ee198700c5427736",
+                "sha256:3262c96a1ca437e7e4763e2843746588a965426550f3797a79fca9c6199c431f",
+                "sha256:326420cbb492172dec84b0f65c80942de6cedb5233c413dd824483989c000608",
+                "sha256:4474f8ea030b5127225b8894d626bb66c01cda098d47a2b0d3429b6700af9fd8",
+                "sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab",
+                "sha256:5ac82e411044fb129bae5cfbeb3ba626acb2af31a8d17d175004b70862a741a7",
+                "sha256:5f84523c076ad14ff5e6c037fe1c89a7f73a3e04cf0377cb4d017014976433f3",
+                "sha256:827dc04b8fa7d07c44de11fabbc888e627fa8293b695e0f99cb544fdfa1bf0d1",
+                "sha256:b4c423ab23291d3945ac61346feeb9a0dc4184999ede5e7c43e1ffb975130ae6",
+                "sha256:bc6bced57f826ca7cb5125a10b23fd0f2fff3b7c4701d64c439a300ce665fff8",
+                "sha256:c01b880ec30b5a6e6aa67b09a2fe3fb30473008c85cd6a67359a1b15ed6d83a4",
+                "sha256:ca233c64c6e40eaa6c66ef97058cdc80e8d0157a443655baa1b2966e812807ca",
+                "sha256:e863072cdf4c72eebf179342c94e6989c67185842d9997960b3e69290b2fa269"
+            ],
+            "index": "pypi",
+            "version": "==3.12"
+        },
+        "selenium": {
+            "hashes": [
+                "sha256:1372101ad23798462038481f92ba1c7fab8385c788b05da6b44318f10ea52422",
+                "sha256:b8a2630fd858636c894960726ca3c94d8277e516ea3a9d81614fb819a5844764"
+            ],
+            "index": "pypi",
+            "version": "==3.12.0"
+        },
+        "six": {
+            "hashes": [
+                "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
+                "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
+            ],
+            "version": "==1.11.0"
+        }
+    }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fd17d1d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,91 @@
+# New Bloodhound
+
+## Requirements
+
+Bloodhound uses pipenv for development process.
+
+If you have pip installed already, installation can be a simple as
+
+```
+pip install --user pipenv
+```
+
+For more information on installing and usage of pipenv, see
+https://docs.pipenv.org/.
+
+Once pipenv is installed, the remaining job of installing should be as simple
+as
+
+```
+pipenv install
+```
+
+If this doesn't work, it should be done from the same directory as the
+`Pipenv` file.
+
+Though possibly annoying, the commands in this file will assume the use of
+`pipenv` but not that the pipenv shell has been activated.
+
+## Setup
+
+The basic setup steps to get running are:
+
+```
+pipenv run python manage.py makemigrations trackers
+pipenv run python manage.py migrate
+```
+
+The above will do the basic database setup.
+
+Note that currently models are in flux and, for the moment, no support should
+be expected for migrations as models change. This will change when basic
+models gain stability.
+
+## Running the development server:
+
+```
+pipenv run python manage.py runserver
+```
+
+## Unit Tests
+
+Unit tests are currently being written with the standard unittest framework.
+This may be replaced with pytest.
+
+The tests may be run with the following command:
+
+```
+pipenv run python manage.py test
+```
+
+Fixtures for tests when required can be generated with:
+
+```
+pipenv python manage.py dumpdata bh-core --format=yaml --indent=2 > bh-core/fixtures/[fixture-name].yaml
+```
+
+## Integration Tests
+
+Selenium tests currently require that Firefox is installed and `geckodriver` is
+also on the path. One way to do this is (example for 64bit linux distributions):
+
+```
+BIN_LOCATION="$HOME/.local/bin"
+PLATFORM_EXT="linux64.tar.gz"
+TMP_DIR=/tmp
+LATEST=$(wget -O - https://github.com/mozilla/geckodriver/releases/latest 2>&1 | awk 'match($0, /geckodriver-(v.*)-'"$PLATFORM_EXT"'/, a) {print a[1]; exit}')
+wget -N -P "$TMP_DIR" "https://github.com/mozilla/geckodriver/releases/download/$LATEST/geckodriver-$LATEST-$PLATFORM_EXT"
+tar -x geckodriver -zf "$TMP_DIR/geckodriver-$LATEST-$PLATFORM_EXT" -O > "$BIN_LOCATION"/geckodriver
+chmod +x "$BIN_LOCATION"/geckodriver
+```
+
+If `$BIN_LOCATION` is on the system path, it should be possible to run the integration tests.
+
+So, assuming the use of pipenv:
+
+```
+pipenv run python functional_tests.py
+```
+
+There are currently not many tests - those that are there are in place to test
+the setup above and assume that there will be useful tests in due course.
diff --git a/bh_core/__init__.py b/bh_core/__init__.py
new file mode 100644
index 0000000..534df97
--- /dev/null
+++ b/bh_core/__init__.py
@@ -0,0 +1,17 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
diff --git a/bh_core/settings.py b/bh_core/settings.py
new file mode 100644
index 0000000..579b660
--- /dev/null
+++ b/bh_core/settings.py
@@ -0,0 +1,142 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+"""
+Django settings for bh_core project.
+
+Generated by 'django-admin startproject' using Django 2.0.3.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/2.0/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/2.0/ref/settings/
+
+SECURITY WARNING: do not use the SECRET_KEY below. This file has only had
+minimal changes following on from the point of generation. Do not expect
+this project to be production ready at this point!
+"""
+
+import os
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = 'zcsm4+ng(*1ct-5ufjreki3d6emagywyn(&$hj8i$2lun*pm&r'
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = True
+
+ALLOWED_HOSTS = []
+
+
+# Application definition
+
+INSTALLED_APPS = [
+    'trackers.apps.TrackersConfig',
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.messages',
+    'django.contrib.staticfiles',
+]
+
+MIDDLEWARE = [
+    'django.middleware.security.SecurityMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = 'bh_core.urls'
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [],
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.debug',
+                'django.template.context_processors.request',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+            ],
+        },
+    },
+]
+
+WSGI_APPLICATION = 'bh_core.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/2.0/ref/settings/#databases
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3',
+        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
+    }
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+    {
+        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+    },
+    {
+        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+    },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/2.0/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/2.0/howto/static-files/
+
+STATIC_URL = '/static/'
diff --git a/bh_core/urls.py b/bh_core/urls.py
new file mode 100644
index 0000000..9bf7a1c
--- /dev/null
+++ b/bh_core/urls.py
@@ -0,0 +1,40 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+"""bh_core URL Configuration
+
+The `urlpatterns` list routes URLs to views. For more information please see:
+    https://docs.djangoproject.com/en/2.0/topics/http/urls/
+Examples:
+Function views
+    1. Add an import:  from my_app import views
+    2. Add a URL to urlpatterns:  path('', views.home, name='home')
+Class-based views
+    1. Add an import:  from other_app.views import Home
+    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
+Including another URLconf
+    1. Import the include() function: from django.urls import include, path
+    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
+"""
+
+from django.contrib import admin
+from django.urls import include, path
+
+urlpatterns = [
+    path('', include('trackers.urls')),
+    # path('admin/', admin.site.urls),
+]
diff --git a/bh_core/wsgi.py b/bh_core/wsgi.py
new file mode 100644
index 0000000..15905c2
--- /dev/null
+++ b/bh_core/wsgi.py
@@ -0,0 +1,33 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+"""
+WSGI config for bh_core project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bh_core.settings")
+
+application = get_wsgi_application()
diff --git a/functional_tests.py b/functional_tests.py
new file mode 100644
index 0000000..df49437
--- /dev/null
+++ b/functional_tests.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+from selenium import webdriver
+import unittest
+
+
+class TicketViewTest(unittest.TestCase):
+    def setUp(self):
+        self.browser = webdriver.Firefox()
+        self.browser.implicitly_wait(3)
+
+    def tearDown(self):
+        self.browser.quit()
+
+    def test_user_can_add_view_and_delete_ticket(self):
+        self.browser.get('http://localhost:8000')
+
+        self.assertIn('Bloodhound', self.browser.title)
+
+
+if __name__ == '__main__':
+    unittest.main(warnings='ignore')
diff --git a/manage.py b/manage.py
new file mode 100755
index 0000000..63afb12
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,33 @@
+#!/usr/bin/env python
+
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+import os
+import sys
+
+if __name__ == "__main__":
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bh_core.settings")
+    try:
+        from django.core.management import execute_from_command_line
+    except ImportError as exc:
+        raise ImportError(
+            "Couldn't import Django. Are you sure it's installed and "
+            "available on your PYTHONPATH environment variable? Did you "
+            "forget to activate a virtual environment?"
+        ) from exc
+    execute_from_command_line(sys.argv)
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..7cf71fa
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+DJANGO_SETTINGS_MODULE = bh_core.settings
diff --git a/trackers/__init__.py b/trackers/__init__.py
new file mode 100644
index 0000000..084b296
--- /dev/null
+++ b/trackers/__init__.py
@@ -0,0 +1,16 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
diff --git a/trackers/admin.py b/trackers/admin.py
new file mode 100644
index 0000000..e1f3d8f
--- /dev/null
+++ b/trackers/admin.py
@@ -0,0 +1,21 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+
+from django.contrib import admin
+
+# Register your models here.
diff --git a/trackers/apps.py b/trackers/apps.py
new file mode 100644
index 0000000..7b9d013
--- /dev/null
+++ b/trackers/apps.py
@@ -0,0 +1,22 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+from django.apps import AppConfig
+
+
+class TrackersConfig(AppConfig):
+    name = 'trackers'
diff --git a/trackers/fixtures/empty.yml b/trackers/fixtures/empty.yml
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/trackers/fixtures/empty.yml
@@ -0,0 +1 @@
+[]
diff --git a/trackers/models.py b/trackers/models.py
new file mode 100644
index 0000000..1887e4e
--- /dev/null
+++ b/trackers/models.py
@@ -0,0 +1,80 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+import difflib
+import functools
+import logging
+import uuid
+
+from django.db import models
+
+logger = logging.getLogger(__name__)
+
+
+class Ticket(models.Model):
+    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+    created = models.DateTimeField(auto_now_add=True, editable=False)
+
+    def last_update(self):
+        last_event = self.changeevent_set.order_by('event_time').last()
+        return self.created if last_event is None else last_event.event_time
+
+    def add_field_event(self, field, newvalue):
+        current_lines = self.get_field_value(field).splitlines(keepends=True)
+        replace_lines = newvalue.splitlines(keepends=True)
+        result = '\n'.join(difflib.ndiff(current_lines, replace_lines))
+
+        tfield, created = TicketField.objects.get_or_create(name=field)
+        c = ChangeEvent(ticket=self, field=tfield, diff=result)
+        c.save()
+
+    def get_field_value(self, field):
+        try:
+            tfield = TicketField.objects.get(name=field)
+        except TicketField.DoesNotExist as e:
+            return ''
+
+        event = self.changeevent_set.filter(field=tfield).order_by('event_time').last()
+        return '' if event is None else event.value()
+
+
+class TicketField(models.Model):
+    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+    name = models.CharField(max_length=32)
+
+class Label(TicketField):
+    pass
+
+class SharedField(TicketField):
+    pass
+
+class ChangeEvent(models.Model):
+    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+    ticket = models.ForeignKey(Ticket, models.CASCADE, editable=False, null=False)
+    field = models.ForeignKey(TicketField, models.CASCADE, editable=False, null=False)
+    event_time = models.DateTimeField(auto_now_add=True, editable=False)
+    diff = models.TextField(editable=False)
+
+    def value(self, which=2):
+        return ''.join(difflib.restore(self.diff.splitlines(keepends=True), which)).strip()
+
+    old_value = functools.partialmethod(value, which=1)
+
+    def __str__(self):
+        return "Change to: {}; Field: {}; Diff: {}".format(
+            self.ticket, self.field, self.diff)
+
diff --git a/trackers/tests.py b/trackers/tests.py
new file mode 100644
index 0000000..cbc666b
--- /dev/null
+++ b/trackers/tests.py
@@ -0,0 +1,93 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+from django.http import HttpRequest
+from django.test import TestCase
+from django.urls import resolve
+from trackers.views import home
+
+
+class HomePageTest(TestCase):
+    def test_root_url_resolves_to_home_page_view(self):
+        found = resolve('/')
+        self.assertEqual(found.func, home)
+
+    def test_home_page_returns_expected_html(self):
+        request = HttpRequest()
+        response = home(request)
+
+        self.assertTrue(response.content.startswith(b'<html>'))
+        self.assertIn(b'<title>Bloodhound Trackers</title>', response.content)
+        self.assertTrue(response.content.endswith(b'</html>'))
+
+
+from trackers.models import Ticket
+
+class TicketModelTest(TestCase):
+    def test_last_update_on_create_returns_created_date(self):
+        t = Ticket()
+        t.save()
+        self.assertEqual(t.created, t.last_update())
+
+    def test_last_update_returns_last_change_date(self):
+        # test may be safer with a fixture with an existing ticket to check
+        t = Ticket()
+        t.save()
+        t.add_field_event('summary', "this is the summary")
+        self.assertNotEqual(t.created, t.last_update())
+
+    def test_ticket_creation(self):
+        # Currently simple but may need updates for required fields
+        pre_count = Ticket.objects.count()
+        t = Ticket()
+        t.save()
+        self.assertEqual(pre_count + 1, Ticket.objects.count())
+
+    def test_ticket_add_field_event(self):
+        field = 'summary'
+        field_value = "this is the summary"
+
+        t = Ticket()
+        t.save()
+        t.add_field_event(field, field_value)
+
+        self.assertEqual(t.get_field_value(field), field_value)
+
+    def test_ticket_add_two_single_line_field_events_same_field(self):
+        field = 'summary'
+        first_field_value = "this is the summary"
+        second_field_value = "this is the replacement summary"
+
+        t = Ticket()
+        t.save()
+        t.add_field_event(field, first_field_value)
+        t.add_field_event(field, second_field_value)
+
+        self.assertEqual(t.get_field_value(field), second_field_value)
+
+    def test_ticket_add_two_multiline_field_events_same_field(self):
+        field = 'summary'
+        first_field_value = "this is the summary\nwith multiple lines"
+        second_field_value = "this is the replacement summary\nwith multiple lines"
+
+        t = Ticket()
+        t.save()
+        t.add_field_event(field, first_field_value)
+        t.add_field_event(field, second_field_value)
+
+        self.assertEqual(t.get_field_value(field), second_field_value)
+
diff --git a/trackers/urls.py b/trackers/urls.py
new file mode 100644
index 0000000..a7a1c75
--- /dev/null
+++ b/trackers/urls.py
@@ -0,0 +1,23 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+from django.urls import path
+from . import views
+
+urlpatterns = [
+    path('', views.home, name='home'),
+]
diff --git a/trackers/views.py b/trackers/views.py
new file mode 100644
index 0000000..6e21c4f
--- /dev/null
+++ b/trackers/views.py
@@ -0,0 +1,22 @@
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing,
+#  software distributed under the License is distributed on an
+#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+#  KIND, either express or implied.  See the License for the
+#  specific language governing permissions and limitations
+#  under the License.
+
+from django.http import HttpResponse
+from django.shortcuts import render
+
+def home(request):
+    return HttpResponse('<html><title>Bloodhound Trackers</title></html>')