summaryrefslogtreecommitdiff
path: root/procurement
diff options
context:
space:
mode:
Diffstat (limited to 'procurement')
-rw-r--r--procurement/__init__.py0
-rw-r--r--procurement/admin.py31
-rw-r--r--procurement/admin_forms.py20
-rw-r--r--procurement/api.py14
-rw-r--r--procurement/apps.py5
-rw-r--r--procurement/forms.py14
-rw-r--r--procurement/migrations/0001_initial.py47
-rw-r--r--procurement/migrations/__init__.py0
-rw-r--r--procurement/models.py83
-rw-r--r--procurement/serializers.py17
-rw-r--r--procurement/static/procurement/css/suppliers.css13
-rw-r--r--procurement/static/procurement/js/component_search.js3
-rw-r--r--procurement/templates/procurement/admin_templates/source_components.html45
-rw-r--r--procurement/templates/procurement/documentation.html76
-rw-r--r--procurement/templates/procurement/includes/supplier_list.html37
-rw-r--r--procurement/templates/procurement/source_components.html61
-rw-r--r--procurement/urls.py12
-rw-r--r--procurement/views.py54
18 files changed, 532 insertions, 0 deletions
diff --git a/procurement/__init__.py b/procurement/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/procurement/__init__.py
diff --git a/procurement/admin.py b/procurement/admin.py
new file mode 100644
index 0000000..993b1b5
--- /dev/null
+++ b/procurement/admin.py
@@ -0,0 +1,31 @@
+from django.contrib import admin
+from django.shortcuts import render_to_response, get_object_or_404
+
+from procurement.admin_forms import ComponentAdminForm
+from procurement.models import Supplier, Component
+
+
+class SupplierAdmin(admin.ModelAdmin):
+ list_display = ('name', 'representative_name', 'representative_email', 'is_authorized', 'updated')
+ filter_horizonal = ('components',)
+
+
+class ComponentAdmin(admin.ModelAdmin):
+ list_display = ('name', 'sku', 'updated')
+ form = ComponentAdminForm
+ source_components_template = 'procurement/admin_templates/source_components.html'
+
+ def source_components(self, request, pk):
+ component = get_object_or_404(Component, pk=pk)
+
+ return render_to_response(self.source_components_template, {
+ 'title': 'Source Suppliers for: %s' % component,
+ 'opts': self.model._meta,
+ 'component': component,
+ 'supplier_results': component.suppliers.filter(is_authorized=True),
+
+ })
+
+
+admin.site.register(Supplier, SupplierAdmin)
+admin.site.register(Component, ComponentAdmin)
diff --git a/procurement/admin_forms.py b/procurement/admin_forms.py
new file mode 100644
index 0000000..b7bbd1c
--- /dev/null
+++ b/procurement/admin_forms.py
@@ -0,0 +1,20 @@
+from django import forms
+from django.contrib.admin.widgets import FilteredSelectMultiple
+
+from procurement.models import Supplier, Component
+
+
+class ComponentAdminForm(forms.ModelForm):
+ suppliers = forms.ModelMultipleChoiceField(
+ queryset=Supplier.objects.filter(is_authorized=True),
+ required=False,
+ widget=FilteredSelectMultiple(
+ verbose_name='Suppliers',
+ is_stacked=False
+ )
+
+ )
+
+ class Meta:
+ model = Component
+ fields = ['name', 'sku', 'suppliers'] \ No newline at end of file
diff --git a/procurement/api.py b/procurement/api.py
new file mode 100644
index 0000000..0419ce8
--- /dev/null
+++ b/procurement/api.py
@@ -0,0 +1,14 @@
+from rest_framework.generics import ListAPIView, RetrieveAPIView
+
+from procurement.models import Component
+from procurement.serializers import ComponentSerializer
+
+
+class ComponentAPIList(ListAPIView):
+ queryset = Component.objects.all()
+ serializer_class = ComponentSerializer
+
+
+class ComponentAPIRetrieve(RetrieveAPIView):
+ queryset = Component.objects.all()
+ serializer_class = ComponentSerializer
diff --git a/procurement/apps.py b/procurement/apps.py
new file mode 100644
index 0000000..94c7e8e
--- /dev/null
+++ b/procurement/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class ProcurementConfig(AppConfig):
+ name = 'procurement'
diff --git a/procurement/forms.py b/procurement/forms.py
new file mode 100644
index 0000000..4e8b0aa
--- /dev/null
+++ b/procurement/forms.py
@@ -0,0 +1,14 @@
+from django import forms
+from procurement.models import Component
+
+
+class ComponentSearchForm(forms.Form):
+ component = forms.ModelChoiceField(
+ queryset=Component.objects.all(),
+ required=False
+ )
+
+ def __init__(self, *args, **kwargs):
+ super(ComponentSearchForm, self).__init__(*args, **kwargs)
+ self.fields['component'].widget.attrs.update({"class": "form-control"})
+
diff --git a/procurement/migrations/0001_initial.py b/procurement/migrations/0001_initial.py
new file mode 100644
index 0000000..ffabc0e
--- /dev/null
+++ b/procurement/migrations/0001_initial.py
@@ -0,0 +1,47 @@
+# Generated by Django 2.1.2 on 2018-10-02 14:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Component',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('updated', models.DateTimeField(auto_now=True)),
+ ('name', models.CharField(max_length=255)),
+ ('sku', models.CharField(max_length=50)),
+ ],
+ options={
+ 'ordering': ('name',),
+ },
+ ),
+ migrations.CreateModel(
+ name='Supplier',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created', models.DateTimeField(auto_now_add=True)),
+ ('updated', models.DateTimeField(auto_now=True)),
+ ('name', models.CharField(max_length=255)),
+ ('representative_name', models.CharField(blank=True, max_length=255, null=True)),
+ ('representative_email', models.EmailField(blank=True, max_length=255, null=True)),
+ ('is_authorized', models.BooleanField()),
+ ],
+ options={
+ 'abstract': False,
+ },
+ ),
+ migrations.AddField(
+ model_name='component',
+ name='suppliers',
+ field=models.ManyToManyField(blank=True, related_name='components', to='procurement.Supplier'),
+ ),
+ ]
diff --git a/procurement/migrations/__init__.py b/procurement/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/procurement/migrations/__init__.py
diff --git a/procurement/models.py b/procurement/models.py
new file mode 100644
index 0000000..1e70736
--- /dev/null
+++ b/procurement/models.py
@@ -0,0 +1,83 @@
+from django.db import models
+from django.utils.timezone import now as timezone_now
+
+MONTH = 30 * 24 * 60 * 60
+WEEK = 7 * 24 * 60 * 60
+DAY = 24 * 60 * 60
+HOUR = 60 * 60
+MINUTE = 60
+
+
+class DashboardModel(models.Model):
+ """
+ Abstract base model for things which will be displayed on the dashboard, adds in created and updated fields,
+ and provides a convenience method which provides a nicely formatted string of the time since update.
+ """
+ created = models.DateTimeField(auto_now_add=True)
+ updated = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ abstract = True
+
+ @property
+ def time_since_update(self):
+ update_delta = timezone_now() - self.updated
+ seconds_since_update = update_delta.seconds
+
+ if seconds_since_update / MONTH >= 1:
+ quantity = seconds_since_update / MONTH
+ units = 'months' if quantity > 1 else 'month'
+
+ elif seconds_since_update / WEEK >= 1:
+ quantity = seconds_since_update / WEEK
+ units = 'weeks' if quantity > 1 else 'week'
+
+ elif seconds_since_update / DAY >= 1:
+ quantity = seconds_since_update / DAY
+ units = 'days' if quantity > 1 else 'day'
+
+ elif seconds_since_update / HOUR >= 1:
+ quantity = seconds_since_update / HOUR
+ units = 'hours' if quantity > 1 else 'hour'
+
+ elif seconds_since_update / MINUTE >= 1:
+ quantity = seconds_since_update / MINUTE
+ units = 'minutes' if quantity > 1 else 'minute'
+
+ else:
+ return "updated just now"
+
+ # Ensure the quantity output is rounded to 2 decimal places
+ base_string = 'updated {quantity:.2f} {units} ago'
+ return base_string.format(quantity=quantity, units=units)
+
+
+class Supplier(DashboardModel):
+ """
+ Model which represents an individual or organisation which supplies components
+ """
+ name = models.CharField(max_length=255)
+ representative_name = models.CharField(max_length=255, null=True, blank=True)
+ representative_email = models.EmailField(max_length=255, null=True, blank=True)
+ is_authorized = models.BooleanField()
+
+ def __str__(self):
+ return '{}'.format(self.name)
+
+
+class Component(DashboardModel):
+ """
+ Model which represents items which may be supplied.
+ """
+ name = models.CharField(max_length=255)
+ sku = models.CharField(max_length=50)
+ suppliers = models.ManyToManyField(Supplier, related_name='components', blank=True)
+
+ class Meta:
+ ordering = ("name",)
+
+ def __str__(self):
+ return '{} ({})'.format(
+ self.name,
+ self.sku
+ )
diff --git a/procurement/serializers.py b/procurement/serializers.py
new file mode 100644
index 0000000..1840837
--- /dev/null
+++ b/procurement/serializers.py
@@ -0,0 +1,17 @@
+from rest_framework import serializers
+from procurement.models import Component, Supplier
+
+
+class SupplierSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Supplier
+ exclude = ('created', 'updated')
+
+
+class ComponentSerializer(serializers.ModelSerializer):
+ text = serializers.CharField(source='__str__', read_only=True)
+ suppliers = SupplierSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = Component
+ exclude = ('created', 'updated')
diff --git a/procurement/static/procurement/css/suppliers.css b/procurement/static/procurement/css/suppliers.css
new file mode 100644
index 0000000..396a446
--- /dev/null
+++ b/procurement/static/procurement/css/suppliers.css
@@ -0,0 +1,13 @@
+/* hide component field on search form */
+#id_component {
+ display: none;
+}
+label[for=id_component] {
+ display: none;
+}
+.record-list {
+ display: block;
+}
+.record-updated{
+ display: block;
+} \ No newline at end of file
diff --git a/procurement/static/procurement/js/component_search.js b/procurement/static/procurement/js/component_search.js
new file mode 100644
index 0000000..cba0a16
--- /dev/null
+++ b/procurement/static/procurement/js/component_search.js
@@ -0,0 +1,3 @@
+$(document).ready(function(){
+ $("#id_component").select2();
+}); \ No newline at end of file
diff --git a/procurement/templates/procurement/admin_templates/source_components.html b/procurement/templates/procurement/admin_templates/source_components.html
new file mode 100644
index 0000000..2deae1e
--- /dev/null
+++ b/procurement/templates/procurement/admin_templates/source_components.html
@@ -0,0 +1,45 @@
+{% extends "admin/base_site.html" %}
+{% load i18n admin_modify %}
+{% block extrahead %}{{ block.super }}
+ <!-- Bootstrap Core CSS -->
+ <link href="/static/prebuilt/bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
+ <!-- Lightsource Custom Styles -->
+ <link href="/static/custom/css/lightsource-styles.css" rel="stylesheet" type="text/css">
+ {% for stylesheet in module_stylesheets %}
+ <link href="/static/{{ stylesheet }}" rel="stylesheet" type="text/css">
+ {% endfor %}
+{% url 'admin:jsi18n' as jsi18nurl %}
+{% endblock %}
+{% block extrastyle %}{{ block.super }}
+{% endblock %}
+{% block bodyclass %}{{ opts.app_label }}-{{ opts.object_name.lower }} change-form{% endblock %}
+{% block breadcrumbs %}{% if not is_popup %}
+<div class="breadcrumbs">
+ <a href="../../">{% trans "Home" %}</a> ›
+ <a href="../">{{ opts.app_label|capfirst|escape }}</a> ›
+ {% trans 'Source Components' %}</div>
+{% endif %}{% endblock %}
+
+{% block content %}
+ <div class="col-lg-8">
+
+ <!-- /.panel -->
+ {% if supplier_results != None %}
+ {% include "suppliers/includes/supplier_list.html" %}
+ {% endif %}
+ </div>
+
+ <!-- jQuery -->
+ <script src="/static/prebuilt/bower_components/jquery/dist/jquery.min.js"></script>
+ <script src="//code.jquery.com/ui/1.11.4/jquery-ui.js"></script>
+ <link rel="stylesheet" href="//code.jquery.com/ui/1.11.4/themes/smoothness/jquery-ui.css">
+
+ <!-- Bootstrap Core JavaScript -->
+ <script src="/static/prebuilt/bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
+
+ <!-- Custom Theme JavaScript -->
+ <script src="/static/prebuilt/dist/js/sb-admin-2.js"></script>
+ {% for script in module_javascripts %}
+ <script src="/static/{{ script }}"></script>
+ {% endfor %}
+{% endblock %} \ No newline at end of file
diff --git a/procurement/templates/procurement/documentation.html b/procurement/templates/procurement/documentation.html
new file mode 100644
index 0000000..4775890
--- /dev/null
+++ b/procurement/templates/procurement/documentation.html
@@ -0,0 +1,76 @@
+{% extends 'base.html' %}
+
+{% block main_content_area %}
+ <div class="col-lg-4">
+ <div class="panel panel-default">
+ <div class="panel-heading">
+ Documentation
+ </div>
+ <!-- /.panel-heading -->
+ <div class="panel-body">
+ <div class="list-group">
+ <a href="#introduction" class="list-group-item">
+ <i class="fa fa-info-circle fa-fw"></i> Introduction
+ </a>
+ <a href="#adding-data" class="list-group-item">
+ <i class="fa fa-database fa-fw"></i> Adding Data
+ </a>
+ <a href="#supplier-list" class="list-group-item">
+ <i class="fa fa-th-list fa-fw"></i> Supplier List
+ </a>
+ <a href="#api" class="list-group-item">
+ <i class="fa fa-bolt fa-fw"></i> API
+ </a>
+ </div>
+ <!-- /.list-group -->
+ </div>
+ <!-- /.panel-body -->
+ </div>
+ <!-- /.panel -->
+ </div>
+
+ <div class="col-lg-8">
+ <div class="panel panel-default">
+ <div class="panel-body">
+ <h2><a name="introduction"></a>Introduction</h2>
+ <p>
+ This is a very simple component and supplier management system. The intention function of the system
+ is to maintain a list of components, and the suppliers who are authorised to supply them. Administrative
+ staff maintain the list of suppliers, components and their associations, while general staff simply
+ use the from page to look up a component, and see its list of suppliers.
+ </p>
+ <p>
+ The system is written in Python (v3.6), using the Django framework (v2.1.2). Documentation for Django
+ is available <a href="https://www.djangoproject.com/">here</a>.
+ </p>
+
+
+ <h2><a name="adding-data"></a>Adding Data</h2>
+ <p>
+ You can manage the components and suppliers using the
+ <a href="{% url 'admin:index' %}">django admin panel</a>.
+ </p>
+
+ <h2><a name="supplier-list"></a>Supplier List</h2>
+ <p>
+ Components and their approved Suppliers can be located under
+ <a href="{% url 'component-search' %}">source components</a>. From this page you may search for a
+ component, and then click the Find Suppliers button to display its Suppliers
+ </p>
+
+ <h2><a name="api"></a>API</h2>
+ <p>
+ A simple REST api is integrated into the system, using the <a href="https://www.django-rest-framework.org/">Django Rest Framework</a>.
+ It provides a means of querying <a href="{% url 'api-component-list' %}">all components</a> or, by providing an id
+ in the url, <a href="{% url 'api-component-retrieve' pk=1 %}">a specific component</a>.
+ </p>
+ </div>
+ <!-- /.panel-body -->
+ </div>
+ <!-- /.panel -->
+ </div>
+
+
+
+
+{% endblock %} \ No newline at end of file
diff --git a/procurement/templates/procurement/includes/supplier_list.html b/procurement/templates/procurement/includes/supplier_list.html
new file mode 100644
index 0000000..028e62c
--- /dev/null
+++ b/procurement/templates/procurement/includes/supplier_list.html
@@ -0,0 +1,37 @@
+
+<!-- Supplier Results -->
+<div class="panel panel-default">
+ <div class="panel-heading">
+ Approved Suppliers for {{ component }}
+ </div>
+ <!-- /.panel-heading -->
+ <div class="panel-body">
+ <div class="table-responsive">
+ <table class="table">
+ <thead>
+ <tr>
+ <th>Company</th>
+ <th>Representative</th>
+ <th>Email</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for supplier in supplier_results %}
+ <tr>
+ <td>{{ supplier.name }}</td>
+ <td>{{ supplier.representative_name }}</td>
+ <td>{{ supplier.representative_email }}</td>
+ </tr>
+ {% empty %}
+ <tr>
+ <td colspan="3">No authorized suppliers found.</td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ </div>
+ <!-- /.table-responsive -->
+ </div>
+ <!-- /.panel-body -->
+</div>
+<!-- / Supplier Results Panel --> \ No newline at end of file
diff --git a/procurement/templates/procurement/source_components.html b/procurement/templates/procurement/source_components.html
new file mode 100644
index 0000000..3eebf84
--- /dev/null
+++ b/procurement/templates/procurement/source_components.html
@@ -0,0 +1,61 @@
+{% extends 'base.html' %}
+{% load static %}
+
+{% block additional_css %}
+ <link href="{% static 'procurement/css/suppliers.css' %}" rel="stylesheet" type="text/css">
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.6-rc.0/css/select2.min.css" rel="stylesheet" />
+{% endblock %}
+
+{% block main_content_area %}
+ <div class="col-lg-8">
+ <div class="panel panel-default">
+ <div class="panel-body">
+ <form id="find-suppliers" method="post">
+ <div class="form-group">
+ {% csrf_token %}
+ {{ form }}
+ </div>
+ <div class="form-group">
+ <input type="submit" value="Find Suppliers" id="find-suppliers-button" class="btn btn-primary">
+ </div>
+ </form>
+ </div>
+ <!-- /.panel-body -->
+ </div>
+ <!-- /.panel -->
+ {% if supplier_results != None %}
+ {% include 'procurement/includes/supplier_list.html' %}
+ {% endif %}
+ </div>
+
+
+ <div class="col-lg-4">
+ <div class="panel panel-default">
+ <div class="panel-heading">
+ Searching Procurement Records
+ </div>
+ <!-- /.panel-heading -->
+ <div class="panel-body">
+ <div class="list-group">
+ <span class="list-group-item">
+ <span class="record-list"><i class="fa fa-parachute-box fa-fw"></i> {{ supplier_count }} Suppliers</span>
+ <span class="record-updated text-muted small"><em>{{ suppliers_last_updated }}</em></span>
+ </span>
+ <span class="list-group-item">
+ <span class="record-list"><i class="fa fa-cog fa-fw"></i> {{ component_count }} Components</span>
+ <span class="record-updated text-muted small"><em>{{ components_last_updated }}</em></span>
+ </span>
+ </div>
+ <!-- /.list-group -->
+ </div>
+ <!-- /.panel-body -->
+ </div>
+ <!-- /.panel -->
+ </div>
+
+{% endblock %}
+
+{% block additional_js %}
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.6-rc.0/js/select2.min.js"></script>
+ <script src="{% static 'procurement/js/component_search.js' %}"></script>
+{% endblock %} \ No newline at end of file
diff --git a/procurement/urls.py b/procurement/urls.py
new file mode 100644
index 0000000..d629635
--- /dev/null
+++ b/procurement/urls.py
@@ -0,0 +1,12 @@
+from django.urls import path
+from procurement.views import ComponentSearchView, DocumentationView
+from procurement.api import ComponentAPIList, ComponentAPIRetrieve
+
+urlpatterns = [
+ path('', ComponentSearchView.as_view(), name='component-search'),
+ path('documentation', DocumentationView.as_view(), name='documentation'),
+
+ # API Urls
+ path('api/components/', ComponentAPIList.as_view(), name='api-component-list'),
+ path('api/components/<int:pk>/', ComponentAPIRetrieve.as_view(), name='api-component-retrieve'),
+]
diff --git a/procurement/views.py b/procurement/views.py
new file mode 100644
index 0000000..8891f89
--- /dev/null
+++ b/procurement/views.py
@@ -0,0 +1,54 @@
+from django.views.generic import FormView, TemplateView
+
+from procurement.forms import ComponentSearchForm
+from procurement.models import Supplier, Component
+
+
+class ComponentSearchView(FormView):
+ template_name = 'procurement/source_components.html'
+ form_class = ComponentSearchForm
+
+ component = None
+ supplier_results = None
+
+ def get_context_data(self):
+ context = super().get_context_data()
+
+ try:
+ suppliers_last_updated = Supplier.objects.latest('updated').time_since_update
+ except Supplier.DoesNotExist:
+ suppliers_last_updated = ''
+
+ try:
+ components_last_updated = Component.objects.latest('updated').time_since_update
+ except Component.DoesNotExist:
+ components_last_updated = ''
+
+ context.update({
+ 'page_name': 'Component Search',
+ 'component': self.component,
+ 'supplier_results': self.supplier_results,
+ 'supplier_count': Supplier.objects.all().count(),
+ 'suppliers_last_updated': suppliers_last_updated,
+ 'component_count': Component.objects.all().count(),
+ 'components_last_updated': components_last_updated,
+ })
+ return context
+
+ def form_valid(self, form):
+ self.component = form.cleaned_data['component']
+ if self.component:
+ self.supplier_results = self.component.suppliers.filter(is_authorized=True)
+
+ return super(ComponentSearchView, self).get(self.request)
+
+
+class DocumentationView(TemplateView):
+ template_name = 'procurement/documentation.html'
+
+ def get_context_data(self):
+ context = super().get_context_data()
+ context.update({
+ 'page_name': 'Documentation',
+ })
+ return context