This commit is contained in:
Flatlogic Bot 2025-11-15 19:14:03 +00:00
parent 873880f7d1
commit 1955ecf8b3
16 changed files with 163 additions and 1 deletions

Binary file not shown.

12
core/decorators.py Normal file
View 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
View 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

View File

View File

View 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.'))

View 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 / GoNo 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 & Signoff'), ('submission-confirmation', 'Submission & Confirmation'), ('post-bid-review-analytics', 'PostBid Review & Analytics')], default='opportunity-discovery', max_length=50),
),
]

View File

@ -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 / GoNo 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 & Signoff'),
('submission-confirmation', 'Submission & Confirmation'),
('post-bid-review-analytics', 'PostBid 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)

View File

@ -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>

View File

@ -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'),

View File

@ -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)

View File

@ -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