1.4
This commit is contained in:
parent
873880f7d1
commit
1955ecf8b3
BIN
core/__pycache__/decorators.cpython-311.pyc
Normal file
BIN
core/__pycache__/decorators.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
12
core/decorators.py
Normal file
12
core/decorators.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.http import HttpResponseForbidden
|
||||||
|
|
||||||
|
def group_required(group_names):
|
||||||
|
def decorator(view_func):
|
||||||
|
def _wrapped_view(request, *args, **kwargs):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
if request.user.groups.filter(name__in=group_names).exists() or request.user.is_superuser:
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
return HttpResponseForbidden("You don't have permission to access this page.")
|
||||||
|
return _wrapped_view
|
||||||
|
return decorator
|
||||||
18
core/etenders_api.py
Normal file
18
core/etenders_api.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import requests
|
||||||
|
|
||||||
|
ETENDERS_API_URL = "https://www.etenders.gov.za/api/v1/tenders"
|
||||||
|
|
||||||
|
def get_tenders():
|
||||||
|
"""
|
||||||
|
Fetches a list of tenders from the eTenders API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: A list of tenders as dictionaries, or None if an error occurs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = requests.get(ETENDERS_API_URL)
|
||||||
|
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"Error fetching tenders from eTenders API: {e}")
|
||||||
|
return None
|
||||||
0
core/management/__init__.py
Normal file
0
core/management/__init__.py
Normal file
0
core/management/commands/__init__.py
Normal file
0
core/management/commands/__init__.py
Normal file
52
core/management/commands/import_tenders.py
Normal file
52
core/management/commands/import_tenders.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from core.etenders_api import get_tenders
|
||||||
|
from core.models import Tender, Company
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Imports tenders from the eTenders API'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write('Importing tenders from eTenders API...')
|
||||||
|
tenders_data = get_tenders()
|
||||||
|
|
||||||
|
if not tenders_data:
|
||||||
|
self.stdout.write(self.style.WARNING('No tenders found or API is unavailable.'))
|
||||||
|
return
|
||||||
|
|
||||||
|
# For now, let's assume a default company exists.
|
||||||
|
# A more robust implementation would be to get the company from the tender data.
|
||||||
|
default_company, created = Company.objects.get_or_create(name='Default Company')
|
||||||
|
|
||||||
|
imported_count = 0
|
||||||
|
for tender_data in tenders_data:
|
||||||
|
# Assuming the API returns these fields. This will need to be adjusted
|
||||||
|
# based on the actual API response.
|
||||||
|
title = tender_data.get('title')
|
||||||
|
description = tender_data.get('description')
|
||||||
|
deadline_str = tender_data.get('deadline')
|
||||||
|
|
||||||
|
if not all([title, description, deadline_str]):
|
||||||
|
self.stdout.write(self.style.WARNING(f'Skipping tender with incomplete data: {tender_data}'))
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
deadline = datetime.fromisoformat(deadline_str)
|
||||||
|
except ValueError:
|
||||||
|
self.stdout.write(self.style.WARNING(f'Skipping tender with invalid deadline format: {deadline_str}'))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if a tender with the same title and company already exists.
|
||||||
|
if Tender.objects.filter(title=title, company=default_company).exists():
|
||||||
|
self.stdout.write(self.style.NOTICE(f'Tender "{title}" already exists. Skipping.'))
|
||||||
|
continue
|
||||||
|
|
||||||
|
Tender.objects.create(
|
||||||
|
company=default_company,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
deadline=deadline,
|
||||||
|
)
|
||||||
|
imported_count += 1
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'Successfully imported {imported_count} new tenders.'))
|
||||||
18
core/migrations/0004_tender_status.py
Normal file
18
core/migrations/0004_tender_status.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-15 19:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0003_document_uploaded_by'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tender',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('opportunity-discovery', 'Opportunity Discovery'), ('qualification', 'Qualification / Go–No Go'), ('tender-registration', 'Tender Registration'), ('bid-planning', 'Bid Planning'), ('team-task-assignment', 'Team & Task Assignment'), ('document-collection-drafting', 'Document Collection & Drafting'), ('internal-review-compliance-check', 'Internal Review & Compliance Check'), ('approvals-sign-off', 'Approvals & Sign‑off'), ('submission-confirmation', 'Submission & Confirmation'), ('post-bid-review-analytics', 'Post‑Bid Review & Analytics')], default='opportunity-discovery', max_length=50),
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0004_tender_status.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0004_tender_status.cpython-311.pyc
Normal file
Binary file not shown.
@ -23,10 +23,23 @@ class Membership(models.Model):
|
|||||||
return f"{self.user.username} - {self.company.name} ({self.role})"
|
return f"{self.user.username} - {self.company.name} ({self.role})"
|
||||||
|
|
||||||
class Tender(models.Model):
|
class Tender(models.Model):
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('opportunity-discovery', 'Opportunity Discovery'),
|
||||||
|
('qualification', 'Qualification / Go–No Go'),
|
||||||
|
('tender-registration', 'Tender Registration'),
|
||||||
|
('bid-planning', 'Bid Planning'),
|
||||||
|
('team-task-assignment', 'Team & Task Assignment'),
|
||||||
|
('document-collection-drafting', 'Document Collection & Drafting'),
|
||||||
|
('internal-review-compliance-check', 'Internal Review & Compliance Check'),
|
||||||
|
('approvals-sign-off', 'Approvals & Sign‑off'),
|
||||||
|
('submission-confirmation', 'Submission & Confirmation'),
|
||||||
|
('post-bid-review-analytics', 'Post‑Bid Review & Analytics'),
|
||||||
|
]
|
||||||
company = models.ForeignKey(Company, on_delete=models.CASCADE)
|
company = models.ForeignKey(Company, on_delete=models.CASCADE)
|
||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
description = models.TextField()
|
description = models.TextField()
|
||||||
deadline = models.DateTimeField()
|
deadline = models.DateTimeField()
|
||||||
|
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='opportunity-discovery')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
<p><strong>Description:</strong></p>
|
<p><strong>Description:</strong></p>
|
||||||
<p>{{ tender.description }}</p>
|
<p>{{ tender.description }}</p>
|
||||||
<p><strong>Deadline:</strong> {{ tender.deadline }}</p>
|
<p><strong>Deadline:</strong> {{ tender.deadline }}</p>
|
||||||
|
<p><strong>Status:</strong> <span class="badge bg-info">{{ tender.get_status_display }}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<a href="{% url 'tender_list' tender.company.id %}" class="btn btn-secondary">Back to Tenders</a>
|
<a href="{% url 'tender_list' tender.company.id %}" class="btn btn-secondary">Back to Tenders</a>
|
||||||
@ -19,6 +20,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="h4">Workflow</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if request.user.is_authenticated and request.user.groups.all|length > 0 %}
|
||||||
|
{% if 'Head of Bids' in request.user.groups.all.0.name or 'Bid Administrator' in request.user.groups.all.0.name %}
|
||||||
|
{% if next_status %}
|
||||||
|
<p>The current step is complete. Ready to move to the next step.</p>
|
||||||
|
<a href="{% url 'update_tender_status' tender.id next_status.0 %}" class="btn btn-success">Approve and move to: {{ next_status.1 }}</a>
|
||||||
|
{% else %}
|
||||||
|
<p>This tender is in the final workflow step.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p>You do not have permission to advance the workflow.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h2 class="h4">Bids</h2>
|
<h2 class="h4">Bids</h2>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ urlpatterns = [
|
|||||||
path('create_company/', views.create_company, name='create_company'),
|
path('create_company/', views.create_company, name='create_company'),
|
||||||
path('company/<int:company_id>/tenders/', views.tender_list, name='tender_list'),
|
path('company/<int:company_id>/tenders/', views.tender_list, name='tender_list'),
|
||||||
path('tender/<int:tender_id>/', views.tender_detail, name='tender_detail'),
|
path('tender/<int:tender_id>/', views.tender_detail, name='tender_detail'),
|
||||||
|
path('tender/<int:tender_id>/update_status/<str:next_status>/', views.update_tender_status, name='update_tender_status'),
|
||||||
path('company/<int:company_id>/create_tender/', views.create_tender, name='create_tender'),
|
path('company/<int:company_id>/create_tender/', views.create_tender, name='create_tender'),
|
||||||
path('tender/<int:tender_id>/update/', views.update_tender, name='update_tender'),
|
path('tender/<int:tender_id>/update/', views.update_tender, name='update_tender'),
|
||||||
path('tender/<int:tender_id>/delete/', views.delete_tender, name='delete_tender'),
|
path('tender/<int:tender_id>/delete/', views.delete_tender, name='delete_tender'),
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from django.contrib.auth import login, authenticate, logout
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from .forms import SignUpForm, CompanyForm, TenderForm, BidForm, DocumentForm, NoteForm, ApprovalForm
|
from .forms import SignUpForm, CompanyForm, TenderForm, BidForm, DocumentForm, NoteForm, ApprovalForm
|
||||||
from .models import Company, Membership, Tender, Bid, Document, Note, Approval
|
from .models import Company, Membership, Tender, Bid, Document, Note, Approval
|
||||||
|
from .decorators import group_required
|
||||||
|
|
||||||
def home(request):
|
def home(request):
|
||||||
return render(request, "core/index.html")
|
return render(request, "core/index.html")
|
||||||
@ -81,16 +82,41 @@ def tender_detail(request, tender_id):
|
|||||||
doc_form = DocumentForm()
|
doc_form = DocumentForm()
|
||||||
note_form = NoteForm()
|
note_form = NoteForm()
|
||||||
|
|
||||||
|
# Workflow logic
|
||||||
|
current_status_index = [i for i, (value, display) in enumerate(Tender.STATUS_CHOICES) if value == tender.status][0]
|
||||||
|
next_status = Tender.STATUS_CHOICES[current_status_index + 1] if current_status_index + 1 < len(Tender.STATUS_CHOICES) else None
|
||||||
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'tender': tender,
|
'tender': tender,
|
||||||
'bids': bids,
|
'bids': bids,
|
||||||
'documents': documents,
|
'documents': documents,
|
||||||
'notes': notes,
|
'notes': notes,
|
||||||
'doc_form': doc_form,
|
'doc_form': doc_form,
|
||||||
'note_form': note_form
|
'note_form': note_form,
|
||||||
|
'next_status': next_status,
|
||||||
}
|
}
|
||||||
return render(request, 'core/tender_detail.html', context)
|
return render(request, 'core/tender_detail.html', context)
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@group_required(['Head of Bids', 'Bid Administrator'])
|
||||||
|
def update_tender_status(request, tender_id, next_status):
|
||||||
|
tender = get_object_or_404(Tender, pk=tender_id)
|
||||||
|
|
||||||
|
# Find the index of the current status
|
||||||
|
current_status_index = [i for i, (value, display) in enumerate(Tender.STATUS_CHOICES) if value == tender.status][0]
|
||||||
|
|
||||||
|
# Find the index of the next status
|
||||||
|
next_status_index = [i for i, (value, display) in enumerate(Tender.STATUS_CHOICES) if value == next_status][0]
|
||||||
|
|
||||||
|
# Check if the next status is valid
|
||||||
|
if next_status_index == current_status_index + 1:
|
||||||
|
tender.status = next_status
|
||||||
|
tender.save()
|
||||||
|
|
||||||
|
return redirect('tender_detail', tender_id=tender.id)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def create_tender(request, company_id):
|
def create_tender(request, company_id):
|
||||||
company = get_object_or_404(Company, pk=company_id)
|
company = get_object_or_404(Company, pk=company_id)
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
Django==5.2.7
|
Django==5.2.7
|
||||||
mysqlclient==2.2.7
|
mysqlclient==2.2.7
|
||||||
python-dotenv==1.1.1
|
python-dotenv==1.1.1
|
||||||
|
requests==2.31.0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user